Compare commits

..

3 Commits

Author SHA1 Message Date
fatedier
9eafcc8a95 server/group: remove blank line between doc comment and type declaration 2026-03-08 18:49:14 +08:00
fatedier
2de56556d9 server/group: replace tautological assertion with require.NotPanics 2026-03-08 18:19:12 +08:00
fatedier
0125ca9437 server/group: refactor group package with shared abstractions and fix concurrency issues
Extract common patterns into reusable components:
- groupRegistry[G]: generic concurrent map for group lifecycle management
- baseGroup: shared plumbing for listener-based groups (TCP, HTTPS, TCPMux)
- Listener: unified virtual listener replacing 3 identical implementations

Fix concurrency issues:
- Stale-pointer race: isCurrent check + errGroupStale + controller retry loops
- Worker generation safety: pass realLn and acceptCh as params instead of reading mutable fields
- Connection leak: close conn on worker panic recovery path
- ABBA deadlock in HTTP UnRegister: consistent lock ordering (group.mu -> registry.mu)
- Round-robin overflow in HTTPGroup: use unsigned modulo

Add unit tests (17 tests) for registry, listener, and baseGroup.
Add TCPMux group load balancing e2e test.
2026-03-08 14:16:48 +08:00
157 changed files with 13295 additions and 8928 deletions

View File

@@ -19,15 +19,15 @@ jobs:
steps: steps:
# environment # environment
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: '0' fetch-depth: '0'
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
# 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@v4 uses: docker/login-action@v3
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@v4 uses: docker/login-action@v3
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@v7 uses: docker/build-push-action@v5
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@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./dockerfiles/Dockerfile-for-frps file: ./dockerfiles/Dockerfile-for-frps

View File

@@ -14,12 +14,12 @@ jobs:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.25'
cache: false cache: false
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Build web assets (frps) - name: Build web assets (frps)

View File

@@ -8,15 +8,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.25'
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
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@v7 uses: goreleaser/goreleaser-action@v6
with: with:
version: latest version: latest
args: release --clean --release-notes=./Release.md args: release --clean --release-notes=./Release.md

View File

@@ -19,7 +19,7 @@ jobs:
actions: write actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v10 - uses: actions/stale@v9
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."

6
.gitignore vendored
View File

@@ -25,12 +25,10 @@ dist/
client.crt client.crt
client.key client.key
node_modules/
# Cache # Cache
*.swp *.swp
# AI # AI
.claude/ CLAUDE.md
AGENTS.md
.sisyphus/ .sisyphus/
.superpowers/

View File

@@ -90,7 +90,6 @@ linters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
- node_modules
formatters: formatters:
enable: enable:
- gci - gci
@@ -113,7 +112,6 @@ 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

View File

@@ -1,39 +0,0 @@
# 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

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -13,16 +13,6 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center"> <div align="center">
## Recall.ai - API for meeting recordings ## Recall.ai - API for meeting recordings
@@ -50,6 +40,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</b> <b>The complete IDE crafted for professional Go developers</b>
</a> </a>
</p> </p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end--> <!--gold sponsors end-->
## What is frp? ## What is frp?
@@ -81,7 +81,6 @@ 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)
@@ -150,9 +149,7 @@ We sincerely appreciate your support for frp.
## Architecture ## Architecture
<p align="center"> ![architecture](/doc/pic/architecture.png)
<img src="/doc/pic/architecture.jpg" alt="architecture" width="760">
</p>
## Example Usage ## Example Usage
@@ -596,7 +593,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 and proxies. The Client Admin UI helps you check and manage frpc's configuration.
Configure an address for admin UI to enable this feature: Configure an address for admin UI to enable this feature:
@@ -609,19 +606,6 @@ 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.

View File

@@ -15,16 +15,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center"> <div align="center">
## Recall.ai - API for meeting recordings ## Recall.ai - API for meeting recordings
@@ -52,6 +42,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</b> <b>The complete IDE crafted for professional Go developers</b>
</a> </a>
</p> </p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end--> <!--gold sponsors end-->
## 为什么使用 frp ## 为什么使用 frp

View File

@@ -1 +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.
## Improvements
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.

View File

@@ -38,8 +38,6 @@ 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)

View File

@@ -80,48 +80,6 @@ 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

View File

@@ -26,9 +26,6 @@ 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)

View File

@@ -119,7 +119,6 @@ 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

View File

@@ -162,44 +162,6 @@ 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 {

View File

@@ -26,8 +26,6 @@ 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)
@@ -84,20 +82,6 @@ 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()
@@ -545,118 +529,3 @@ 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)
}

View File

@@ -19,7 +19,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http"
"os" "os"
"runtime" "runtime"
"sync" "sync"
@@ -163,6 +162,15 @@ 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
@@ -183,17 +191,6 @@ 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,
@@ -232,25 +229,22 @@ 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 := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) { if err := svr.vnetController.Run(); err != nil {
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", webServer.Address()) log.Infof("admin server listen on %s", svr.webServer.Address())
if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := svr.webServer.Run(); err != nil {
log.Warnf("admin server exit with error: %v", err) log.Warnf("admin server exit with error: %v", err)
} }
}() }()
@@ -261,7 +255,6 @@ 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)
} }
@@ -504,10 +497,6 @@ 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) {
@@ -521,17 +510,6 @@ 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,

View File

@@ -1,120 +1,14 @@
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"}

View File

@@ -191,13 +191,6 @@ 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() (net.Conn, error)
msgTransporter transport.MessageTransporter msgTransporter transport.MessageTransporter

View File

@@ -1,80 +0,0 @@
# 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. 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).
## 4. 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
```
## 5. 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`)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

BIN
doc/pic/architecture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,11 +1,8 @@
FROM node:22 AS web-builder FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frpc/ /web/frpc/
WORKDIR /web
RUN npm install
WORKDIR /web/frpc WORKDIR /web/frpc
COPY web/frpc/ ./
RUN npm install
RUN npm run build RUN npm run build
FROM golang:1.25 AS building FROM golang:1.25 AS building

View File

@@ -1,11 +1,8 @@
FROM node:22 AS web-builder FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frps/ /web/frps/
WORKDIR /web
RUN npm install
WORKDIR /web/frps WORKDIR /web/frps
COPY web/frps/ ./
RUN npm install
RUN npm run build RUN npm run build
FROM golang:1.25 AS building FROM golang:1.25 AS building

32
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25.0
require ( require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/fatedier/golib v0.6.0 github.com/fatedier/golib v0.5.1
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/v3 v3.1.1 github.com/pion/stun/v2 v2.0.0
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,15 +22,15 @@ 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.11.1 github.com/stretchr/testify v1.10.0
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.49.0 golang.org/x/crypto v0.41.0
golang.org/x/net v0.52.0 golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.28.0 golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.16.0
golang.org/x/time v0.10.0 golang.org/x/time v0.5.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
@@ -38,7 +38,7 @@ require (
) )
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // 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,9 +51,10 @@ 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/v3 v3.0.10 // indirect github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/logging v0.2.4 // indirect github.com/pion/logging v0.2.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/transport/v2 v2.2.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
@@ -65,12 +66,11 @@ 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.33.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.42.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

105
go.sum
View File

@@ -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.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
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.6.0 h1:/mgBZZbkbMhIEZoXf7nV8knpUDzas/b+2ruYKxx1lww= github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
github.com/fatedier/golib v0.6.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
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,14 +78,16 @@ 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/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/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=
@@ -126,10 +128,11 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
@@ -146,12 +149,11 @@ 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=
@@ -159,54 +161,89 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/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=

View File

@@ -30,7 +30,6 @@ import (
"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"
) )
@@ -76,64 +75,14 @@ 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
tokenSource oauth2.TokenSource tokenGenerator *clientcredentials.Config
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}
@@ -151,42 +100,30 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
EndpointParams: eps, EndpointParams: eps,
} }
// Build the context that TokenSource will use for all future HTTP requests. // Create custom HTTP client if needed
// context.Background() is appropriate here because the token source is var httpClient *http.Client
// long-lived and outlives any single request.
ctx := context.Background()
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" { if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL) var err error
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,
tokenSource: &oidcTokenSource{ tokenGenerator: tokenGenerator,
source: cachingSource, httpClient: httpClient,
fallbackCfg: tokenGenerator,
fallbackCtx: ctx,
},
}, nil }, nil
} }
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) { func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
tokenObj, err := auth.tokenSource.Token() ctx := context.Background()
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)
} }

View File

@@ -2,10 +2,6 @@ package auth_test
import ( import (
"context" "context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -66,188 +62,3 @@ 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")
}

View File

@@ -88,11 +88,6 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
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
} }

View File

@@ -1,57 +0,0 @@
// 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, "; "))
}

View File

@@ -1,78 +0,0 @@
// 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")
})
}

View File

@@ -19,7 +19,7 @@ import (
"net" "net"
"time" "time"
"github.com/pion/stun/v3" "github.com/pion/stun/v2"
) )
var responseTimeout = 3 * time.Second var responseTimeout = 3 * time.Second

View File

@@ -21,7 +21,7 @@ import (
"strconv" "strconv"
"github.com/fatedier/golib/crypto" "github.com/fatedier/golib/crypto"
"github.com/pion/stun/v3" "github.com/pion/stun/v2"
"github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/msg"
) )

View File

@@ -100,11 +100,7 @@ func (s *Server) Run() error {
} }
func (s *Server) Close() error { func (s *Server) Close() error {
err := s.hs.Close() return s.hs.Close()
if s.ln != nil {
_ = s.ln.Close()
}
return err
} }
type RouterRegisterHelper struct { type RouterRegisterHelper struct {

View File

@@ -131,9 +131,6 @@ func (c *Controller) handlePacket(buf []byte) {
} }
func (c *Controller) Stop() error { func (c *Controller) Stop() error {
if c.tun == nil {
return nil
}
return c.tun.Close() return c.tun.Close()
} }

View File

@@ -26,7 +26,7 @@ var _ = ginkgo.Describe("[Feature: Example]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })

View File

@@ -241,31 +241,6 @@ func (f *Framework) AllocPort() int {
return port return port
} }
func (f *Framework) AllocPortExcludingRanges(ranges ...[2]int) int {
for range 1000 {
port := f.portAllocator.Get()
ExpectTrue(port > 0, "alloc port failed")
inExcludedRange := false
for _, portRange := range ranges {
if port >= portRange[0] && port <= portRange[1] {
inExcludedRange = true
break
}
}
if inExcludedRange {
f.portAllocator.Release(port)
continue
}
f.allocatedPorts = append(f.allocatedPorts, port)
return port
}
Failf("alloc port outside excluded ranges failed")
return 0
}
func (f *Framework) ReleasePort(port int) { func (f *Framework) ReleasePort(port int) {
f.portAllocator.Release(port) f.portAllocator.Release(port)
} }

View File

@@ -3,84 +3,67 @@ package framework
import ( import (
"fmt" "fmt"
"maps" "maps"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "slices"
"time" "time"
"github.com/fatedier/frp/pkg/config"
flog "github.com/fatedier/frp/pkg/util/log" flog "github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/process" "github.com/fatedier/frp/test/e2e/pkg/process"
) )
// RunProcesses starts one frps and zero or more frpc processes from templates. // RunProcesses run multiple processes from templates.
func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) { // The first template should always be frps.
templates := append([]string{serverTemplate}, clientTemplates...) func (f *Framework) RunProcesses(serverTemplates []string, clientTemplates []string) ([]*process.Process, []*process.Process) {
templates := slices.Concat(serverTemplates, clientTemplates)
outs, ports, err := f.RenderTemplates(templates) outs, ports, err := f.RenderTemplates(templates)
ExpectNoError(err) ExpectNoError(err)
ExpectTrue(len(templates) > 0)
maps.Copy(f.usedPorts, ports) maps.Copy(f.usedPorts, ports)
// Start frps. currentServerProcesses := make([]*process.Process, 0, len(serverTemplates))
serverPath := filepath.Join(f.TempDirectory, "frp-e2e-server-0") for i := range serverTemplates {
err = os.WriteFile(serverPath, []byte(outs[0]), 0o600) path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-server-%d", i))
ExpectNoError(err) err = os.WriteFile(path, []byte(outs[i]), 0o600)
if TestContext.Debug {
flog.Debugf("[%s] %s", serverPath, outs[0])
}
serverProcess := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", serverPath}, f.osEnvs)
f.serverConfPaths = append(f.serverConfPaths, serverPath)
f.serverProcesses = append(f.serverProcesses, serverProcess)
err = serverProcess.Start()
ExpectNoError(err)
if port, ok := ports[consts.PortServerName]; ok {
ExpectNoError(WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 5*time.Second))
} else {
time.Sleep(2 * time.Second)
}
// Start frpc(s).
clientProcesses := make([]*process.Process, 0, len(clientTemplates))
for i := range clientTemplates {
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i))
err = os.WriteFile(path, []byte(outs[1+i]), 0o600)
ExpectNoError(err) ExpectNoError(err)
if TestContext.Debug { if TestContext.Debug {
flog.Debugf("[%s] %s", path, outs[1+i]) flog.Debugf("[%s] %s", path, outs[i])
}
p := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", path}, f.osEnvs)
f.serverConfPaths = append(f.serverConfPaths, path)
f.serverProcesses = append(f.serverProcesses, p)
currentServerProcesses = append(currentServerProcesses, p)
err = p.Start()
ExpectNoError(err)
time.Sleep(500 * time.Millisecond)
}
time.Sleep(2 * time.Second)
currentClientProcesses := make([]*process.Process, 0, len(clientTemplates))
for i := range clientTemplates {
index := i + len(serverTemplates)
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i))
err = os.WriteFile(path, []byte(outs[index]), 0o600)
ExpectNoError(err)
if TestContext.Debug {
flog.Debugf("[%s] %s", path, outs[index])
} }
p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs) p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs)
f.clientConfPaths = append(f.clientConfPaths, path) f.clientConfPaths = append(f.clientConfPaths, path)
f.clientProcesses = append(f.clientProcesses, p) f.clientProcesses = append(f.clientProcesses, p)
clientProcesses = append(clientProcesses, p) currentClientProcesses = append(currentClientProcesses, p)
err = p.Start() err = p.Start()
ExpectNoError(err) ExpectNoError(err)
time.Sleep(500 * time.Millisecond)
} }
// Wait for each client's proxies to register with frps. time.Sleep(3 * time.Second)
// If any client has no proxies (e.g. visitor-only), fall back to sleep
// for the remaining time since visitors have no deterministic readiness signal.
allConfirmed := len(clientProcesses) > 0
start := time.Now()
for i, p := range clientProcesses {
configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
if !waitForClientProxyReady(configPath, p, 5*time.Second) {
allConfirmed = false
}
}
if len(clientProcesses) > 0 && !allConfirmed {
remaining := 1500*time.Millisecond - time.Since(start)
if remaining > 0 {
time.Sleep(remaining)
}
}
return serverProcess, clientProcesses return currentServerProcesses, currentClientProcesses
} }
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) { func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
@@ -88,13 +71,11 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
f.serverProcesses = append(f.serverProcesses, p) f.serverProcesses = append(f.serverProcesses, p)
err := p.Start() err := p.Start()
if err != nil { if err != nil {
return p, p.Output(), err return p, p.StdOutput(), err
} }
select { // Give frps extra time to finish binding ports before proceeding.
case <-p.Done(): time.Sleep(4 * time.Second)
case <-time.After(2 * time.Second): return p, p.StdOutput(), nil
}
return p, p.Output(), nil
} }
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) { func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
@@ -102,13 +83,10 @@ func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
f.clientProcesses = append(f.clientProcesses, p) f.clientProcesses = append(f.clientProcesses, p)
err := p.Start() err := p.Start()
if err != nil { if err != nil {
return p, p.Output(), err return p, p.StdOutput(), err
} }
select { time.Sleep(2 * time.Second)
case <-p.Done(): return p, p.StdOutput(), nil
case <-time.After(1500 * time.Millisecond):
}
return p, p.Output(), nil
} }
func (f *Framework) GenerateConfigFile(content string) string { func (f *Framework) GenerateConfigFile(content string) string {
@@ -118,74 +96,3 @@ func (f *Framework) GenerateConfigFile(content string) string {
ExpectNoError(err) ExpectNoError(err)
return path return path
} }
// waitForClientProxyReady parses the client config to extract proxy names,
// then waits for each proxy's "start proxy success" log in the process output.
// Returns true only if proxies were expected and all registered successfully.
func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {
_, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)
if err != nil || len(proxyCfgs) == 0 {
return false
}
// Use a single deadline so the total wait across all proxies does not exceed timeout.
deadline := time.Now().Add(timeout)
for _, cfg := range proxyCfgs {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
name := cfg.GetBaseConfig().Name
pattern := fmt.Sprintf("[%s] start proxy success", name)
if err := p.WaitForOutput(pattern, 1, remaining); err != nil {
return false
}
}
return true
}
// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.
func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {
if interval <= 0 {
return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr)
}
if timeout <= 0 {
return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr)
}
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr)
}
dialTimeout := min(interval, remaining)
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil
}
conn.Close()
time.Sleep(min(interval, time.Until(deadline)))
}
}
// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
func WaitForTCPReady(addr string, timeout time.Duration) error {
if timeout <= 0 {
return fmt.Errorf("invalid timeout for TCP readiness on %s: timeout must be positive", addr)
}
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
lastErr = err
time.Sleep(50 * time.Millisecond)
}
if lastErr == nil {
return fmt.Errorf("timeout waiting for TCP readiness on %s before any dial attempt", addr)
}
return fmt.Errorf("timeout waiting for TCP readiness on %s: %w", addr, lastErr)
}

View File

@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
for domain := range strings.SplitSeq(test.customDomains, ",") { for domain := range strings.SplitSeq(test.customDomains, ",") {
@@ -235,7 +235,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -419,7 +419,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
} }
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests { for _, test := range tests {
timeout := time.Second timeout := time.Second
@@ -497,7 +497,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
// Request without HTTP connect should get error // Request without HTTP connect should get error
framework.NewRequestExpect(f). framework.NewRequestExpect(f).

View File

@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port, framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port) framework.TCPEchoServerPort, p3Port)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure() framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure() framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
admin_pwd = admin admin_pwd = admin
`, dashboardPort) `, dashboardPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz") r.HTTP().HTTPPath("/healthz")
@@ -116,7 +116,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remote_port = %d remote_port = %d
`, adminPort, framework.TCPEchoServerPort, testPort) `, adminPort, framework.TCPEchoServerPort, testPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure() framework.NewRequestExpect(f).Port(testPort).Ensure()

View File

@@ -76,7 +76,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf) clientConfs = append(clientConfs, client2Conf)
} }
f.RunProcesses(serverConf, clientConfs) f.RunProcesses([]string{serverConf}, clientConfs)
if configures.testDelay > 0 { if configures.testDelay > 0 {
time.Sleep(configures.testDelay) time.Sleep(configures.testDelay)

View File

@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName) `, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"}) f.SetEnvs([]string{"FRP_TOKEN=123"})
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure() framework.NewRequestExpect(f).PortName(portName).Ensure()
}) })

View File

@@ -56,7 +56,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = /bar locations = /bar
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tests := []struct { tests := []struct {
path string path string
@@ -111,7 +111,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = normal.example.com custom_domains = normal.example.com
`, fooPort, barPort, otherPort) `, fooPort, barPort, otherPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// user1 // user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort). framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
http_pwd = test http_pwd = test
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not set auth header // not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -188,7 +188,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = *.example.com custom_domains = *.example.com
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not match host // not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -238,7 +238,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = bar subdomain = bar
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// foo // foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort). framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -279,7 +279,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
header_X-From-Where = frp header_X-From-Where = frp
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not set auth header // not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -312,7 +312,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
host_header_rewrite = rewrite.example.com host_header_rewrite = rewrite.example.com
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -360,7 +360,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = 127.0.0.1 custom_domains = 127.0.0.1
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)} u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)

View File

@@ -20,8 +20,6 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
ginkgo.It("Ports Whitelist", func() { ginkgo.It("Ports Whitelist", func() {
serverConf := consts.LegacyDefaultServerConfig serverConf := consts.LegacyDefaultServerConfig
clientConf := consts.LegacyDefaultClientConfig clientConf := consts.LegacyDefaultClientConfig
tcpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
udpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
serverConf += ` serverConf += `
allow_ports = 10000-11000,11002,12000-13000 allow_ports = 10000-11000,11002,12000-13000
@@ -39,8 +37,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
[tcp-port-not-allowed] [tcp-port-not-allowed]
type = tcp type = tcp
local_port = {{ .%s }} local_port = {{ .%s }}
remote_port = %d remote_port = 11001
`, framework.TCPEchoServerPort, tcpPortNotAllowed) `, framework.TCPEchoServerPort)
clientConf += fmt.Sprintf(` clientConf += fmt.Sprintf(`
[tcp-port-unavailable] [tcp-port-unavailable]
type = tcp type = tcp
@@ -57,17 +55,17 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
[udp-port-not-allowed] [udp-port-not-allowed]
type = udp type = udp
local_port = {{ .%s }} local_port = {{ .%s }}
remote_port = %d remote_port = 11003
`, framework.UDPEchoServerPort, udpPortNotAllowed) `, framework.UDPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// TCP // TCP
// Allowed in range // Allowed in range
framework.NewRequestExpect(f).PortName(tcpPortName).Ensure() framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
// Not Allowed // Not Allowed
framework.NewRequestExpect(f).Port(tcpPortNotAllowed).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()
// Unavailable, already bind by frps // Unavailable, already bind by frps
framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure() framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()
@@ -78,7 +76,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
// Not Allowed // Not Allowed
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.UDP().Port(udpPortNotAllowed) r.UDP().Port(11003)
}).ExpectError(true).Ensure() }).ExpectError(true).Ensure()
}) })
@@ -99,7 +97,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
local_port = {{ .%s }} local_port = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort) `, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
client := f.APIClientForFrpc(adminPort) client := f.APIClientForFrpc(adminPort)
@@ -140,7 +138,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com custom_domains = example.com
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com") r.HTTP().HTTPHost("example.com")
@@ -167,7 +165,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com custom_domains = example.com
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz") r.HTTP().HTTPPath("/healthz")

View File

@@ -76,7 +76,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com custom_domains = normal.example.com
`, fooPort, barPort, otherPort) `, fooPort, barPort, otherPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// user1 // user1
framework.NewRequestExpect(f).Explain("user1"). framework.NewRequestExpect(f).Explain("user1").
@@ -121,7 +121,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
http_pwd = test http_pwd = test
`, fooPort) `, fooPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not set auth header // not set auth header
framework.NewRequestExpect(f).Explain("no auth"). framework.NewRequestExpect(f).Explain("no auth").
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com custom_domains = normal.example.com
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {

View File

@@ -41,7 +41,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallback_timeout_ms = 200 fallback_timeout_ms = 200
`, framework.TCPEchoServerPort, bindPortName) `, framework.TCPEchoServerPort, bindPortName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
r.Timeout(time.Second) r.Timeout(time.Second)

View File

@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
bandwidth_limit = 10KB bandwidth_limit = 10KB
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now() start := time.Now()
@@ -89,7 +89,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remote_port = %d remote_port = %d
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now() start := time.Now()

View File

@@ -88,7 +88,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
group_key = 123 group_key = 123
`, fooPort, remotePort, barPort, remotePort) `, fooPort, remotePort, barPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
fooCount := 0 fooCount := 0
barCount := 0 barCount := 0
@@ -144,7 +144,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_interval_s = 1 health_check_interval_s = 1
`, fooPort, remotePort, barPort, remotePort) `, fooPort, remotePort, barPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// check foo and bar is ok // check foo and bar is ok
results := []string{} results := []string{}
@@ -213,7 +213,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_url = /healthz health_check_url = /healthz
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// send first HTTP request // send first HTTP request
var contents []string var contents []string

View File

@@ -38,7 +38,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort) `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()

View File

@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)

View File

@@ -44,7 +44,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
custom_domains = normal.example.com custom_domains = normal.example.com
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2 proxy_protocol_version = v2
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool { framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content)) log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -136,7 +136,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2 proxy_protocol_version = v2
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com") r.HTTP().HTTPHost("normal.example.com")

View File

@@ -70,7 +70,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure() framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123 plugin_http_passwd = 123
`, remotePort) `, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// http proxy, no auth info // http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_passwd = 123 plugin_passwd = 123
`, remotePort) `, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// http proxy, no auth info // http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -168,7 +168,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123 plugin_http_passwd = 123
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory) `, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// from tcp proxy // from tcp proxy
framework.NewRequestExpect(f).Request( framework.NewRequestExpect(f).Request(
@@ -202,7 +202,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_local_addr = 127.0.0.1:%d plugin_local_addr = 127.0.0.1:%d
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -246,7 +246,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s plugin_key_path = %s
`, localPort, crtPath, keyPath) `, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),
@@ -290,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s plugin_key_path = %s
`, localPort, crtPath, keyPath) `, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)

View File

@@ -71,7 +71,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort2) `, framework.TCPEchoServerPort, remotePort2)
f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -119,7 +119,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -153,7 +153,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = 0 remote_port = 0
`, framework.TCPEchoServerPort) `, framework.TCPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -195,7 +195,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
_, clients := f.RunProcesses(serverConf, []string{clientConf}) _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -250,7 +250,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -297,7 +297,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -342,7 +342,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -389,7 +389,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d remote_port = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()

View File

@@ -1,258 +0,0 @@
// 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 oidcserver provides a minimal mock OIDC server for e2e testing.
// It implements three endpoints:
// - /.well-known/openid-configuration (discovery)
// - /jwks (JSON Web Key Set)
// - /token (client_credentials grant)
package oidcserver
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net"
"net/http"
"strconv"
"sync/atomic"
"time"
)
type Server struct {
bindAddr string
bindPort int
l net.Listener
hs *http.Server
privateKey *rsa.PrivateKey
kid string
clientID string
clientSecret string
audience string
subject string
expiresIn int // seconds; 0 means omit expires_in from token response
tokenRequestCount atomic.Int64
}
type Option func(*Server)
func WithBindPort(port int) Option {
return func(s *Server) { s.bindPort = port }
}
func WithClientCredentials(id, secret string) Option {
return func(s *Server) {
s.clientID = id
s.clientSecret = secret
}
}
func WithAudience(aud string) Option {
return func(s *Server) { s.audience = aud }
}
func WithSubject(sub string) Option {
return func(s *Server) { s.subject = sub }
}
func WithExpiresIn(seconds int) Option {
return func(s *Server) { s.expiresIn = seconds }
}
func New(options ...Option) *Server {
s := &Server{
bindAddr: "127.0.0.1",
kid: "test-key-1",
clientID: "test-client",
clientSecret: "test-secret",
audience: "frps",
subject: "test-service",
expiresIn: 3600,
}
for _, opt := range options {
opt(s)
}
return s
}
func (s *Server) Run() error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("generate RSA key: %w", err)
}
s.privateKey = key
s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))
if err != nil {
return err
}
s.bindPort = s.l.Addr().(*net.TCPAddr).Port
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
mux.HandleFunc("/jwks", s.handleJWKS)
mux.HandleFunc("/token", s.handleToken)
s.hs = &http.Server{
Handler: mux,
ReadHeaderTimeout: time.Minute,
}
go func() { _ = s.hs.Serve(s.l) }()
return nil
}
func (s *Server) Close() error {
if s.hs != nil {
return s.hs.Close()
}
return nil
}
func (s *Server) BindAddr() string { return s.bindAddr }
func (s *Server) BindPort() int { return s.bindPort }
func (s *Server) Issuer() string {
return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort)
}
func (s *Server) TokenEndpoint() string {
return s.Issuer() + "/token"
}
// TokenRequestCount returns the number of successful token requests served.
func (s *Server) TokenRequestCount() int64 {
return s.tokenRequestCount.Load()
}
func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
issuer := s.Issuer()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer,
"token_endpoint": issuer + "/token",
"jwks_uri": issuer + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
}
func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
pub := &s.privateKey.PublicKey
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": s.kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
},
},
})
}
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_request",
})
return
}
if r.FormValue("grant_type") != "client_credentials" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "unsupported_grant_type",
})
return
}
// Accept credentials from Basic Auth or form body.
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if clientID != s.clientID || clientSecret != s.clientSecret {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_client",
})
return
}
token, err := s.signJWT()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]any{
"access_token": token,
"token_type": "Bearer",
}
if s.expiresIn > 0 {
resp["expires_in"] = s.expiresIn
}
s.tokenRequestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) signJWT() (string, error) {
now := time.Now()
header, _ := json.Marshal(map[string]string{
"alg": "RS256",
"kid": s.kid,
"typ": "JWT",
})
claims, _ := json.Marshal(map[string]any{
"iss": s.Issuer(),
"sub": s.subject,
"aud": s.audience,
"iat": now.Unix(),
"exp": now.Add(1 * time.Hour).Unix(),
})
headerB64 := base64.RawURLEncoding.EncodeToString(header)
claimsB64 := base64.RawURLEncoding.EncodeToString(claims)
signingInput := headerB64 + "." + claimsB64
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])
if err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

View File

@@ -3,44 +3,15 @@ package process
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt"
"os/exec" "os/exec"
"strings"
"sync"
"time"
) )
// SafeBuffer is a thread-safe wrapper around bytes.Buffer.
// It is safe to call Write and String concurrently.
type SafeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *SafeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *SafeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
type Process struct { type Process struct {
cmd *exec.Cmd cmd *exec.Cmd
cancel context.CancelFunc cancel context.CancelFunc
errorOutput *SafeBuffer errorOutput *bytes.Buffer
stdOutput *SafeBuffer stdOutput *bytes.Buffer
done chan struct{}
closeOne sync.Once
waitErr error
started bool
beforeStopHandler func() beforeStopHandler func()
stopped bool stopped bool
} }
@@ -56,45 +27,20 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
p := &Process{ p := &Process{
cmd: cmd, cmd: cmd,
cancel: cancel, cancel: cancel,
done: make(chan struct{}),
} }
p.errorOutput = &SafeBuffer{} p.errorOutput = bytes.NewBufferString("")
p.stdOutput = &SafeBuffer{} p.stdOutput = bytes.NewBufferString("")
cmd.Stderr = p.errorOutput cmd.Stderr = p.errorOutput
cmd.Stdout = p.stdOutput cmd.Stdout = p.stdOutput
return p return p
} }
func (p *Process) Start() error { func (p *Process) Start() error {
if p.started { return p.cmd.Start()
return errors.New("process already started")
}
p.started = true
err := p.cmd.Start()
if err != nil {
p.waitErr = err
p.closeDone()
return err
}
go func() {
p.waitErr = p.cmd.Wait()
p.closeDone()
}()
return nil
}
func (p *Process) closeDone() {
p.closeOne.Do(func() { close(p.done) })
}
// Done returns a channel that is closed when the process exits.
func (p *Process) Done() <-chan struct{} {
return p.done
} }
func (p *Process) Stop() error { func (p *Process) Stop() error {
if p.stopped || !p.started { if p.stopped {
return nil return nil
} }
defer func() { defer func() {
@@ -104,8 +50,7 @@ func (p *Process) Stop() error {
p.beforeStopHandler() p.beforeStopHandler()
} }
p.cancel() p.cancel()
<-p.done return p.cmd.Wait()
return p.waitErr
} }
func (p *Process) ErrorOutput() string { func (p *Process) ErrorOutput() string {
@@ -116,38 +61,6 @@ func (p *Process) StdOutput() string {
return p.stdOutput.String() return p.stdOutput.String()
} }
func (p *Process) Output() string {
return p.stdOutput.String() + p.errorOutput.String()
}
// CountOutput returns how many times pattern appears in the current accumulated output.
func (p *Process) CountOutput(pattern string) int {
return strings.Count(p.Output(), pattern)
}
func (p *Process) SetBeforeStopHandler(fn func()) { func (p *Process) SetBeforeStopHandler(fn func()) {
p.beforeStopHandler = fn p.beforeStopHandler = fn
} }
// WaitForOutput polls the combined process output until the pattern is found
// count time(s) or the timeout is reached. It also returns early if the process exits.
func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
output := p.Output()
if strings.Count(output, pattern) >= count {
return nil
}
select {
case <-p.Done():
// Process exited, check one last time.
output = p.Output()
if strings.Count(output, pattern) >= count {
return nil
}
return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern)
case <-time.After(25 * time.Millisecond):
}
}
return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern)
}

View File

@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Annotations]", func() {
"frp.e2e.test/bar" = "value2" "frp.e2e.test/bar" = "value2"
`, framework.TCPEchoServerPort, p1Port) `, framework.TCPEchoServerPort, p1Port)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure() framework.NewRequestExpect(f).Port(p1Port).Ensure()

View File

@@ -83,7 +83,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
for domain := range strings.SplitSeq(test.customDomains, ",") { for domain := range strings.SplitSeq(test.customDomains, ",") {
@@ -240,7 +240,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -426,7 +426,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
} }
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests { for _, test := range tests {
timeout := time.Second timeout := time.Second
@@ -505,7 +505,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
// Request without HTTP connect should get error // Request without HTTP connect should get error
framework.NewRequestExpect(f). framework.NewRequestExpect(f).

View File

@@ -51,7 +51,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port, framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port) framework.TCPEchoServerPort, p3Port)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure() framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure() framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
webServer.password = "admin" webServer.password = "admin"
`, dashboardPort) `, dashboardPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz") r.HTTP().HTTPPath("/healthz")
@@ -120,7 +120,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remotePort = %d remotePort = %d
`, adminPort, framework.TCPEchoServerPort, testPort) `, adminPort, framework.TCPEchoServerPort, testPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure() framework.NewRequestExpect(f).Port(testPort).Ensure()

View File

@@ -78,7 +78,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf) clientConfs = append(clientConfs, client2Conf)
} }
f.RunProcesses(serverConf, clientConfs) f.RunProcesses([]string{serverConf}, clientConfs)
if configures.testDelay > 0 { if configures.testDelay > 0 {
time.Sleep(configures.testDelay) time.Sleep(configures.testDelay)

View File

@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName) `, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"}) f.SetEnvs([]string{"FRP_TOKEN=123"})
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure() framework.NewRequestExpect(f).PortName(portName).Ensure()
}) })
@@ -69,7 +69,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
escapeTemplate("{{- end }}"), escapeTemplate("{{- end }}"),
) )
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
client := f.APIClientForFrpc(adminPort) client := f.APIClientForFrpc(adminPort)
checkProxyFn := func(name string, localPort, remotePort int) { checkProxyFn := func(name string, localPort, remotePort int) {
@@ -149,7 +149,7 @@ proxies:
remotePort: %d remotePort: %d
`, port.GenName("Server"), framework.TCPEchoServerPort, remotePort) `, port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -161,7 +161,7 @@ proxies:
"proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`, "proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`,
port.GenName("Server"), framework.TCPEchoServerPort, remotePort) port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
}) })

View File

@@ -59,7 +59,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = ["/bar"] locations = ["/bar"]
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tests := []struct { tests := []struct {
path string path string
@@ -117,7 +117,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort) `, fooPort, barPort, otherPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// user1 // user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort). framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -159,7 +159,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
httpPassword = "test" httpPassword = "test"
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not set auth header // not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -196,7 +196,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["*.example.com"] customDomains = ["*.example.com"]
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not match host // not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -248,7 +248,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = "bar" subdomain = "bar"
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// foo // foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort). framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -290,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
requestHeaders.set.x-from-where = "frp" requestHeaders.set.x-from-where = "frp"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -323,7 +323,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
responseHeaders.set.x-from-where = "frp" responseHeaders.set.x-from-where = "frp"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
hostHeaderRewrite = "rewrite.example.com" hostHeaderRewrite = "rewrite.example.com"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["127.0.0.1"] customDomains = ["127.0.0.1"]
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)} u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
@@ -447,7 +447,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {

View File

@@ -1,192 +0,0 @@
// 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 basic
import (
"fmt"
"time"
"github.com/onsi/ginkgo/v2"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/oidcserver"
"github.com/fatedier/frp/test/e2e/pkg/port"
)
var _ = ginkgo.Describe("[Feature: OIDC]", func() {
f := framework.NewDefaultFramework()
ginkgo.It("should work with OIDC authentication", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("should authenticate heartbeats with OIDC", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
serverPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
log.level = "trace"
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, serverPort, oidcSrv.Issuer())
clientConf := fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
log.level = "trace"
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
transport.heartbeatInterval = 1
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)
serverConfigPath := f.GenerateConfigFile(serverConf)
clientConfigPath := f.GenerateConfigFile(clientConf)
_, _, err := f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err)
clientProcess, _, err := f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err)
// Wait for several authenticated heartbeat cycles instead of a fixed sleep.
err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second)
framework.ExpectNoError(err)
// Proxy should still work: heartbeat auth has not failed.
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("should work when token has no expires_in", func() {
oidcSrv := oidcserver.New(
oidcserver.WithBindPort(f.AllocPort()),
oidcserver.WithExpiresIn(0),
)
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
transport.heartbeatInterval = 1
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
countAfterLogin := oidcSrv.TokenRequestCount()
// Wait for several heartbeat cycles instead of a fixed sleep.
// Each heartbeat fetches a fresh token in non-caching mode.
err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second)
framework.ExpectNoError(err)
framework.NewRequestExpect(f).PortName(portName).Ensure()
// Each heartbeat should have fetched a new token (non-caching mode).
countAfterHeartbeats := oidcSrv.TokenRequestCount()
framework.ExpectTrue(
countAfterHeartbeats > countAfterLogin,
"expected additional token requests for heartbeats, got %d before and %d after",
countAfterLogin, countAfterHeartbeats,
)
})
ginkgo.It("should reject invalid OIDC credentials", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "wrong-secret"
auth.oidc.tokenEndpointURL = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
})
})

View File

@@ -20,8 +20,6 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
ginkgo.It("Ports Whitelist", func() { ginkgo.It("Ports Whitelist", func() {
serverConf := consts.DefaultServerConfig serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig clientConf := consts.DefaultClientConfig
tcpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
udpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
serverConf += ` serverConf += `
allowPorts = [ allowPorts = [
@@ -45,8 +43,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
name = "tcp-port-not-allowed" name = "tcp-port-not-allowed"
type = "tcp" type = "tcp"
localPort = {{ .%s }} localPort = {{ .%s }}
remotePort = %d remotePort = 11001
`, framework.TCPEchoServerPort, tcpPortNotAllowed) `, framework.TCPEchoServerPort)
clientConf += fmt.Sprintf(` clientConf += fmt.Sprintf(`
[[proxies]] [[proxies]]
name = "tcp-port-unavailable" name = "tcp-port-unavailable"
@@ -66,17 +64,17 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
name = "udp-port-not-allowed" name = "udp-port-not-allowed"
type = "udp" type = "udp"
localPort = {{ .%s }} localPort = {{ .%s }}
remotePort = %d remotePort = 11003
`, framework.UDPEchoServerPort, udpPortNotAllowed) `, framework.UDPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// TCP // TCP
// Allowed in range // Allowed in range
framework.NewRequestExpect(f).PortName(tcpPortName).Ensure() framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
// Not Allowed // Not Allowed
framework.NewRequestExpect(f).Port(tcpPortNotAllowed).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()
// Unavailable, already bind by frps // Unavailable, already bind by frps
framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure() framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()
@@ -87,7 +85,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
// Not Allowed // Not Allowed
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.UDP().Port(udpPortNotAllowed) r.UDP().Port(11003)
}).ExpectError(true).Ensure() }).ExpectError(true).Ensure()
}) })
@@ -110,7 +108,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
localPort = {{ .%s }} localPort = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort) `, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
client := f.APIClientForFrpc(adminPort) client := f.APIClientForFrpc(adminPort)
@@ -152,7 +150,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"] customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com") r.HTTP().HTTPHost("example.com")
@@ -180,7 +178,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"] customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort) `, framework.HTTPSimpleServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz") r.HTTP().HTTPPath("/healthz")

View File

@@ -79,7 +79,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort) `, fooPort, barPort, otherPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// user1 // user1
framework.NewRequestExpect(f).Explain("user1"). framework.NewRequestExpect(f).Explain("user1").
@@ -125,7 +125,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
httpPassword = "test" httpPassword = "test"
`, fooPort) `, fooPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// not set auth header // not set auth header
framework.NewRequestExpect(f).Explain("no auth"). framework.NewRequestExpect(f).Explain("no auth").
@@ -209,7 +209,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {

View File

@@ -16,11 +16,8 @@ package basic
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"time"
"github.com/onsi/ginkgo/v2" "github.com/onsi/ginkgo/v2"
@@ -76,7 +73,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }} remotePort = {{ .%s }}
`, tokenContent, framework.TCPEchoServerPort, portName) `, tokenContent, framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure() framework.NewRequestExpect(f).PortName(portName).Ensure()
}) })
@@ -112,7 +109,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }} remotePort = {{ .%s }}
`, tokenFile, framework.TCPEchoServerPort, portName) `, tokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure() framework.NewRequestExpect(f).PortName(portName).Ensure()
}) })
@@ -153,7 +150,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }} remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName) `, clientTokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure() framework.NewRequestExpect(f).PortName(portName).Ensure()
}) })
@@ -193,7 +190,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }} remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName) `, clientTokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// This should fail due to token mismatch - the client should not be able to connect // This should fail due to token mismatch - the client should not be able to connect
// We expect the request to fail because the proxy tunnel is not established // We expect the request to fail because the proxy tunnel is not established
@@ -201,27 +198,32 @@ remotePort = {{ .%s }}
}) })
ginkgo.It("should fail with non-existent token file", func() { ginkgo.It("should fail with non-existent token file", func() {
// This test verifies that server fails to start when tokenSource points to non-existent file
// We'll verify this by checking that the configuration loading itself fails
// Create a config that references a non-existent file
tmpDir := f.TempDirectory tmpDir := f.TempDirectory
nonExistentFile := filepath.Join(tmpDir, "non_existent_token") nonExistentFile := filepath.Join(tmpDir, "non_existent_token")
serverPort := f.AllocPort() serverConf := consts.DefaultServerConfig
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0" // Server config with non-existent tokenSource file
bindPort = %d serverConf += fmt.Sprintf(`
auth.tokenSource.type = "file" auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s" auth.tokenSource.file.path = "%s"
`, serverPort, nonExistentFile) `, nonExistentFile)
serverConfigPath := f.GenerateConfigFile(serverConf) // The test expectation is that this will fail during the RunProcesses call
// because the server cannot load the configuration due to missing token file
defer func() {
if r := recover(); r != nil {
// Expected: server should fail to start due to missing file
ginkgo.By(fmt.Sprintf("Server correctly failed to start: %v", r))
}
}()
_, _, _ = f.RunFrps("-c", serverConfigPath) // This should cause a panic or error during server startup
f.RunProcesses([]string{serverConf}, []string{})
// Server should have failed to start, so the port should not be listening.
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(serverPort)), 1*time.Second)
if err == nil {
conn.Close()
}
framework.ExpectTrue(err != nil, "server should not be listening on port %d", serverPort)
}) })
}) })

View File

@@ -42,7 +42,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallbackTimeoutMs = 200 fallbackTimeoutMs = 200
`, framework.TCPEchoServerPort, bindPortName) `, framework.TCPEchoServerPort, bindPortName)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f). framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
r.Timeout(time.Second) r.Timeout(time.Second)

View File

@@ -36,7 +36,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
transport.bandwidthLimit = "10KB" transport.bandwidthLimit = "10KB"
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now() start := time.Now()
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remotePort = %d remotePort = %d
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now() start := time.Now()

View File

@@ -41,24 +41,24 @@ var _ = ginkgo.Describe("[Feature: Chaos]", func() {
// 2. stop frps, expect request failed // 2. stop frps, expect request failed
_ = ps.Stop() _ = ps.Stop()
time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 3. restart frps, expect request success // 3. restart frps, expect request success
successCount := pc.CountOutput("[tcp] start proxy success")
_, _, err = f.RunFrps("-c", serverConfigPath) _, _, err = f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err) framework.ExpectNoError(err)
framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second)) time.Sleep(2 * time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
// 4. stop frpc, expect request failed // 4. stop frpc, expect request failed
_ = pc.Stop() _ = pc.Stop()
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second)) time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 5. restart frpc, expect request success // 5. restart frpc, expect request success
newPc, _, err := f.RunFrpc("-c", clientConfigPath) _, _, err = f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err) framework.ExpectNoError(err)
framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second)) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
}) })

View File

@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123" loadBalancer.groupKey = "123"
`, fooPort, remotePort, barPort, remotePort) `, fooPort, remotePort, barPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
fooCount := 0 fooCount := 0
barCount := 0 barCount := 0
@@ -157,7 +157,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123" loadBalancer.groupKey = "123"
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
fooCount := 0 fooCount := 0
barCount := 0 barCount := 0
@@ -222,7 +222,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123" loadBalancer.groupKey = "123"
`, fooPort, barPort) `, fooPort, barPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort) proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort)
fooCount := 0 fooCount := 0
@@ -286,7 +286,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.intervalSeconds = 1 healthCheck.intervalSeconds = 1
`, fooPort, remotePort, barPort, remotePort) `, fooPort, remotePort, barPort, remotePort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// check foo and bar is ok // check foo and bar is ok
results := []string{} results := []string{}
@@ -299,17 +299,15 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"}) framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok // close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close() barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second)) time.Sleep(2 * time.Second)
for range 10 { for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure() framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
} }
// resume bar server, check foo and bar is ok // resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer) f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second)) time.Sleep(2 * time.Second)
results = []string{} results = []string{}
for range 10 { for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool { framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
@@ -359,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.path = "/healthz" healthCheck.path = "/healthz"
`, fooPort, barPort) `, fooPort, barPort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// send first HTTP request // send first HTTP request
var contents []string var contents []string
@@ -389,17 +387,15 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"}) framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok // close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close() barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second)) time.Sleep(2 * time.Second)
results = doFooBarHTTPRequest(vhostPort, "example.com") results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo"}) framework.ExpectContainElements(results, []string{"foo"})
framework.ExpectNotContainElements(results, []string{"bar"}) framework.ExpectNotContainElements(results, []string{"bar"})
// resume bar server, check foo and bar is ok // resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer) f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second)) time.Sleep(2 * time.Second)
results = doFooBarHTTPRequest(vhostPort, "example.com") results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"}) framework.ExpectContainElements(results, []string{"foo", "bar"})
}) })

View File

@@ -37,7 +37,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort) `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()

View File

@@ -34,7 +34,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)

View File

@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"] customDomains = ["normal.example.com"]
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort). framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) { RequestModify(func(r *request.Request) {
@@ -112,7 +112,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d" localAddr = "127.0.0.1:%d"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d" localAddr = "127.0.0.1:%d"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),
@@ -212,7 +212,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2" transport.proxyProtocolVersion = "v2"
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool { framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content)) log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -262,7 +262,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2" transport.proxyProtocolVersion = "v2"
`, localPort, remotePort) `, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool { framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content)) log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -309,7 +309,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2" transport.proxyProtocolVersion = "v2"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com") r.HTTP().HTTPHost("normal.example.com")

View File

@@ -3,8 +3,6 @@ package features
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net"
"strconv"
"time" "time"
"github.com/onsi/ginkgo/v2" "github.com/onsi/ginkgo/v2"
@@ -27,8 +25,7 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d sshTunnelGateway.bindPort = %d
`, sshPort) `, sshPort)
f.RunProcesses(serverConf, nil) f.RunProcesses([]string{serverConf}, nil)
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort) localPort := f.PortByName(framework.TCPEchoServerPort)
remotePort := f.AllocPort() remotePort := f.AllocPort()
@@ -52,8 +49,7 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort) `, vhostPort, sshPort)
f.RunProcesses(serverConf, nil) f.RunProcesses([]string{serverConf}, nil)
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.HTTPSimpleServerPort) localPort := f.PortByName(framework.HTTPSimpleServerPort)
tc := ssh.NewTunnelClient( tc := ssh.NewTunnelClient(
@@ -80,8 +76,7 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort) `, vhostPort, sshPort)
f.RunProcesses(serverConf, nil) f.RunProcesses([]string{serverConf}, nil)
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort() localPort := f.AllocPort()
testDomain := "test.example.com" testDomain := "test.example.com"
@@ -123,8 +118,7 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d sshTunnelGateway.bindPort = %d
`, tcpmuxPort, sshPort) `, tcpmuxPort, sshPort)
f.RunProcesses(serverConf, nil) f.RunProcesses([]string{serverConf}, nil)
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort() localPort := f.AllocPort()
testDomain := "test.example.com" testDomain := "test.example.com"
@@ -179,8 +173,7 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
bindPort = %d bindPort = %d
`, bindPort) `, bindPort)
f.RunProcesses(serverConf, []string{visitorConf}) f.RunProcesses([]string{serverConf}, []string{visitorConf})
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort) localPort := f.PortByName(framework.TCPEchoServerPort)
tc := ssh.NewTunnelClient( tc := ssh.NewTunnelClient(

View File

@@ -30,8 +30,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
@@ -52,7 +52,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second)) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -71,8 +71,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second)) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure() framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2 proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
@@ -107,8 +107,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second)) time.Sleep(time.Second)
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort2).Ensure() framework.NewRequestExpect(f).Port(remotePort2).Ensure()
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
}) })
@@ -126,8 +125,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
@@ -148,7 +147,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second)) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
@@ -157,7 +156,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second)) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
}) })
@@ -174,8 +173,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
@@ -196,6 +195,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200 return resp.Code == 200
}) })
time.Sleep(500 * time.Millisecond)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
}).Ensure(func(resp *request.Response) bool { }).Ensure(func(resp *request.Response) bool {
@@ -224,8 +225,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
webServer.port = %d webServer.port = %d
`, adminPort) `, adminPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
@@ -246,8 +247,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
invalidBody, _ := json.Marshal(map[string]any{ invalidBody, _ := json.Marshal(map[string]any{
"name": "bad-proxy", "name": "bad-proxy",
@@ -279,8 +280,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
path = "%s/store.json" path = "%s/store.json"
`, adminPort, f.TempDirectory) `, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) time.Sleep(500 * time.Millisecond)
createBody, _ := json.Marshal(map[string]any{ createBody, _ := json.Marshal(map[string]any{
"name": "proxy-a", "name": "proxy-a",

View File

@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
} }
// run frps and frpc // run frps and frpc
f.RunProcesses(serverConf, []string{clientConf.String()}) f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
for _, test := range tests { for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure() framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -98,7 +98,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123" httpPassword = "123"
`, remotePort) `, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// http proxy, no auth info // http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -132,7 +132,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
password = "123" password = "123"
`, remotePort) `, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// http proxy, no auth info // http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -182,7 +182,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123" httpPassword = "123"
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory) `, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
// from tcp proxy // from tcp proxy
framework.NewRequestExpect(f).Request( framework.NewRequestExpect(f).Request(
@@ -218,7 +218,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
localAddr = "127.0.0.1:%d" localAddr = "127.0.0.1:%d"
`, localPort) `, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -264,7 +264,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s" keyPath = "%s"
`, localPort, crtPath, keyPath) `, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s" keyPath = "%s"
`, localPort, crtPath, keyPath) `, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "") tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err) framework.ExpectNoError(err)
@@ -350,7 +350,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
hostHeaderRewrite = "rewrite.test.com" hostHeaderRewrite = "rewrite.test.com"
`, remotePort, localPort) `, remotePort, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),
@@ -385,7 +385,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
requestHeaders.set.x-from-where = "frp" requestHeaders.set.x-from-where = "frp"
`, remotePort, localPort) `, remotePort, localPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),
@@ -431,7 +431,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s" keyPath = "%s"
`, localPort, crtPath, keyPath) `, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
localServer := httpserver.New( localServer := httpserver.New(
httpserver.WithBindPort(localPort), httpserver.WithBindPort(localPort),

View File

@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort2) `, framework.TCPEchoServerPort, remotePort2)
f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -160,7 +160,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = 0 remotePort = 0
`, framework.TCPEchoServerPort) `, framework.TCPEchoServerPort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
}) })
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
_, clients := f.RunProcesses(serverConf, []string{clientConf}) _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -261,7 +261,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d remotePort = %d
`, framework.TCPEchoServerPort, remotePort) `, framework.TCPEchoServerPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf}) f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort).Ensure()

View File

@@ -1,7 +1,7 @@
.PHONY: dist install build preview lint .PHONY: dist install build preview lint
install: install:
@cd .. && npm install @npm install
build: install build: install
@npm run build @npm run build

View File

@@ -7,39 +7,28 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ConfigField: typeof import('./src/components/ConfigField.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton']
ConfigSection: typeof import('./src/components/ConfigSection.vue')['default'] ElCard: typeof import('element-plus/es')['ElCard']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default'] KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default']
ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default']
ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default']
ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default']
ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default']
ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default']
ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default']
ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default']
ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default']
ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StatusPills: typeof import('./src/components/StatusPills.vue')['default'] StatCard: typeof import('./src/components/StatCard.vue')['default']
StringListEditor: typeof import('./src/components/StringListEditor.vue')['default']
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
} }
export interface ComponentCustomProperties { export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@@ -3,7 +3,6 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>frp client</title> <title>frp client</title>
</head> </head>

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
}, },
"dependencies": { "dependencies": {
"element-plus": "^2.13.0", "element-plus": "^2.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

View File

@@ -2,160 +2,140 @@
<div id="app"> <div id="app">
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<div class="brand-section"> <div class="header-top">
<button v-if="isMobile" class="hamburger-btn" @click="toggleSidebar" aria-label="Toggle menu"> <div class="brand-section">
<span class="hamburger-icon">&#9776;</span> <div class="logo-wrapper">
</button> <LogoIcon class="logo-icon" />
<div class="logo-wrapper"> </div>
<LogoIcon class="logo-icon" /> <span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge client-badge">Client</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div> </div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge">Client</span>
</div> </div>
<div class="header-controls"> <nav class="nav-bar">
<a <router-link to="/" class="nav-link" active-class="active"
class="github-link" >Overview</router-link
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
> >
<GitHubIcon class="github-icon" /> <router-link to="/configure" class="nav-link" active-class="active"
</a> >Configure</router-link
<el-switch >
v-model="isDark" </nav>
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
</div> </div>
</header> </header>
<div class="layout"> <main id="content">
<!-- Mobile overlay --> <router-view></router-view>
<div </main>
v-if="isMobile && sidebarOpen"
class="sidebar-overlay"
@click="closeSidebar"
/>
<aside class="sidebar" :class="{ 'mobile-open': isMobile && sidebarOpen }">
<nav class="sidebar-nav">
<router-link
to="/proxies"
class="sidebar-link"
:class="{ active: route.path.startsWith('/proxies') }"
@click="closeSidebar"
>
Proxies
</router-link>
<router-link
to="/visitors"
class="sidebar-link"
:class="{ active: route.path.startsWith('/visitors') }"
@click="closeSidebar"
>
Visitors
</router-link>
<router-link
to="/config"
class="sidebar-link"
:class="{ active: route.path === '/config' }"
@click="closeSidebar"
>
Config
</router-link>
</nav>
</aside>
<main id="content">
<router-view></router-view>
</main>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useDark } from '@vueuse/core' import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component' import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component' import LogoIcon from './assets/icons/logo.svg?component'
import { useResponsive } from './composables/useResponsive'
const route = useRoute() const route = useRoute()
const isDark = useDark() const isDark = useDark()
const { isMobile } = useResponsive()
const sidebarOpen = ref(false) const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
const toggleSidebar = () => { if (route.path === '/configure') return 'Configure'
sidebarOpen.value = !sidebarOpen.value if (route.path === '/proxies/create') return 'Create Proxy'
} if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
return 'Edit Proxy'
const closeSidebar = () => { if (route.path === '/visitors/create') return 'Create Visitor'
sidebarOpen.value = false if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
} return 'Edit Visitor'
return ''
// Auto-close sidebar on route change
watch(() => route.path, () => {
if (isMobile.value) {
closeSidebar()
}
}) })
</script> </script>
<style lang="scss"> <style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body { body {
margin: 0; margin: 0;
font-family: ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica, font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif; Arial, sans-serif;
} }
*,
:after,
:before {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
overflow: hidden;
}
#app { #app {
height: 100vh; min-height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $color-bg-secondary; background-color: var(--el-bg-color-page);
} }
// Header
.header { .header {
flex-shrink: 0; position: sticky;
background: $color-bg-primary; top: 0;
border-bottom: 1px solid $color-border-light; z-index: 100;
height: $header-height; background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
} }
.header-content { .header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
}
.header-top {
height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 100%;
padding: 0 $spacing-xl;
} }
.brand-section { .brand-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: 12px;
} }
.logo-wrapper { .logo-wrapper {
@@ -164,30 +144,41 @@ html, body {
} }
.logo-icon { .logo-icon {
width: 28px; width: 32px;
height: 28px; height: 32px;
} }
.divider { .divider {
color: $color-border; color: var(--header-border);
font-size: 22px; font-size: 24px;
font-weight: 200; font-weight: 200;
} }
.brand-name { .brand-name {
font-weight: $font-weight-semibold; font-weight: 600;
font-size: $font-size-xl; font-size: 18px;
color: $color-text-primary; color: var(--text-primary);
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.badge { .badge {
font-size: $font-size-xs; font-size: 12px;
font-weight: $font-weight-medium; color: var(--text-secondary);
color: $color-text-muted; background: var(--hover-bg);
background: $color-bg-muted;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 99px;
border: 1px solid var(--header-border);
}
.badge.client-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
font-weight: 500;
}
html.dark .badge.client-badge {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
} }
.header-controls { .header-controls {
@@ -197,17 +188,17 @@ html, body {
} }
.github-link { .github-link {
@include flex-center; width: 26px;
width: 28px; height: 26px;
height: 28px; display: flex;
border-radius: $radius-sm; align-items: center;
color: $color-text-secondary; justify-content: center;
transition: all $transition-fast; border-radius: 50%;
color: var(--text-primary);
&:hover { transition: background 0.2s;
background: $color-bg-hover; background: transparent;
color: $color-text-primary; border: 1px solid transparent;
} cursor: pointer;
} }
.github-icon { .github-icon {
@@ -215,10 +206,15 @@ html, body {
height: 18px; height: 18px;
} }
.github-link:hover {
background: var(--hover-bg);
border-color: var(--header-border);
}
.theme-switch { .theme-switch {
--el-switch-on-color: #2c2c3a; --el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2; --el-switch-off-color: #f2f2f2;
--el-switch-border-color: var(--color-border-light); --el-switch-border-color: var(--header-border);
} }
html.dark .theme-switch { html.dark .theme-switch {
@@ -229,300 +225,47 @@ html.dark .theme-switch {
color: #909399 !important; color: #909399 !important;
} }
// Layout .nav-bar {
.layout { height: 48px;
flex: 1;
display: flex; display: flex;
overflow: hidden; align-items: center;
gap: 24px;
} }
.sidebar { .nav-link {
width: $sidebar-width;
flex-shrink: 0;
border-right: 1px solid $color-border-light;
padding: $spacing-lg $spacing-md;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-nav {
@include flex-column;
gap: 2px;
}
.sidebar-link {
display: block;
text-decoration: none; text-decoration: none;
font-size: $font-size-lg; font-size: 14px;
color: $color-text-secondary; color: var(--text-secondary);
padding: 10px $spacing-md; padding: 8px 0;
border-radius: $radius-sm; border-bottom: 2px solid transparent;
transition: all $transition-fast; transition: all 0.2s;
&:hover {
color: $color-text-primary;
background: $color-bg-hover;
}
&.active {
color: $color-text-primary;
background: $color-bg-hover;
font-weight: $font-weight-medium;
}
} }
// Hamburger button (mobile only) .nav-link:hover {
.hamburger-btn { color: var(--text-primary);
@include flex-center;
width: 36px;
height: 36px;
border: none;
border-radius: $radius-sm;
background: transparent;
cursor: pointer;
padding: 0;
transition: background $transition-fast;
&:hover {
background: $color-bg-hover;
}
} }
.hamburger-icon { .nav-link.active {
font-size: 20px; color: var(--active-link);
line-height: 1; border-bottom-color: var(--active-link);
color: $color-text-primary;
}
// Mobile overlay
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
} }
#content { #content {
flex: 1; flex: 1;
min-width: 0; width: 100%;
overflow: hidden; padding: 40px;
background: $color-bg-primary; max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
} }
// Common page styles @media (max-width: 768px) {
.page-title {
font-size: $font-size-xl + 2px;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin: 0;
}
.page-subtitle {
font-size: $font-size-md;
color: $color-text-muted;
margin: $spacing-sm 0 0;
}
.icon-btn {
@include flex-center;
width: 32px;
height: 32px;
border: none;
border-radius: $radius-sm;
background: transparent;
color: $color-text-muted;
cursor: pointer;
transition: all $transition-fast;
&:hover {
background: $color-bg-hover;
color: $color-text-primary;
}
}
.search-input {
width: 200px;
.el-input__wrapper {
border-radius: 10px;
background: $color-bg-tertiary;
box-shadow: 0 0 0 1px $color-border inset;
&.is-focus {
box-shadow: 0 0 0 1px $color-text-light inset;
}
}
.el-input__inner {
color: $color-text-primary;
}
.el-input__prefix {
color: $color-text-muted;
}
@include mobile {
flex: 1;
width: auto;
}
}
// Element Plus global overrides
.el-button {
font-weight: $font-weight-medium;
}
.el-tag {
font-weight: $font-weight-medium;
}
.el-switch {
--el-switch-on-color: #606266;
--el-switch-off-color: #dcdfe6;
}
html.dark .el-switch {
--el-switch-on-color: #b0b0b0;
--el-switch-off-color: #404040;
}
.el-radio {
--el-radio-text-color: var(--color-text-primary) !important;
--el-radio-input-border-color-hover: #606266 !important;
--el-color-primary: #606266 !important;
}
.el-form-item {
margin-bottom: 16px;
}
.el-loading-mask {
border-radius: $radius-md;
}
// Select overrides
.el-select__wrapper {
border-radius: $radius-md !important;
box-shadow: 0 0 0 1px $color-border-light inset !important;
transition: all $transition-fast;
&:hover {
box-shadow: 0 0 0 1px $color-border inset !important;
}
&.is-focused {
box-shadow: 0 0 0 1px $color-border inset !important;
}
}
.el-select-dropdown {
border-radius: 12px !important;
border: 1px solid $color-border-light !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
padding: 4px !important;
}
.el-select-dropdown__item {
border-radius: $radius-sm;
margin: 2px 0;
transition: background $transition-fast;
&.is-selected {
color: $color-text-primary;
font-weight: $font-weight-medium;
}
}
// Input overrides
.el-input__wrapper {
border-radius: $radius-md !important;
box-shadow: 0 0 0 1px $color-border-light inset !important;
transition: all $transition-fast;
&:hover {
box-shadow: 0 0 0 1px $color-border inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px $color-border inset !important;
}
}
// Status pill (shared)
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
padding: 3px 10px;
border-radius: 10px;
text-transform: capitalize;
&.running {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.error {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&.waiting {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
&.disabled {
background: $color-bg-muted;
color: $color-text-light;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
}
// Mobile
@include mobile {
.header-content { .header-content {
padding: 0 $spacing-lg; padding: 0 20px;
}
.sidebar {
position: fixed;
top: $header-height;
left: 0;
bottom: 0;
z-index: 100;
background: $color-bg-primary;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border-right: 1px solid $color-border-light;
&.mobile-open {
transform: translateX(0);
}
}
.sidebar-nav {
flex-direction: column;
gap: 2px;
} }
#content { #content {
width: 100%; padding: 20px;
}
// Select dropdown overflow prevention
.el-select-dropdown {
max-width: calc(100vw - 32px);
} }
} }
</style> </style>

View File

@@ -5,7 +5,7 @@ import type {
ProxyDefinition, ProxyDefinition,
VisitorListResp, VisitorListResp,
VisitorDefinition, VisitorDefinition,
} from '../types' } from '../types/proxy'
export const getStatus = () => { export const getStatus = () => {
return http.get<StatusResponse>('/api/status') return http.get<StatusResponse>('/api/status')
@@ -23,19 +23,6 @@ export const reloadConfig = () => {
return http.get<void>('/api/reload') return http.get<void>('/api/reload')
} }
// Config lookup API (any source)
export const getProxyConfig = (name: string) => {
return http.get<ProxyDefinition>(
`/api/proxy/${encodeURIComponent(name)}/config`,
)
}
export const getVisitorConfig = (name: string) => {
return http.get<VisitorDefinition>(
`/api/visitor/${encodeURIComponent(name)}/config`,
)
}
// Store API - Proxies // Store API - Proxies
export const listStoreProxies = () => { export const listStoreProxies = () => {
return http.get<ProxyListResp>('/api/store/proxies') return http.get<ProxyListResp>('/api/store/proxies')

View File

@@ -1,33 +0,0 @@
@use '@shared/css/mixins' as *;
/* Shared form layout styles for proxy/visitor form sections */
.field-row {
display: grid;
gap: 16px;
align-items: start;
}
.field-row.two-col {
grid-template-columns: 1fr 1fr;
}
.field-row.three-col {
grid-template-columns: 1fr 1fr 1fr;
}
.field-grow {
min-width: 0;
}
.switch-field :deep(.el-form-item__content) {
min-height: 32px;
display: flex;
align-items: center;
}
@include mobile {
.field-row.two-col,
.field-row.three-col {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,105 @@
/* Modern Base Styles */
* {
box-sizing: border-box;
}
/* Smooth transitions for Element Plus components */
.el-button,
.el-card,
.el-input,
.el-select,
.el-tag {
transition: all 0.3s ease;
}
/* Card hover effects */
.el-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
/* Better scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Better form layouts */
.el-form-item {
margin-bottom: 18px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.el-row {
margin-left: 0 !important;
margin-right: 0 !important;
}
.el-col {
padding-left: 10px !important;
padding-right: 10px !important;
}
}
/* Input enhancements */
.el-input__wrapper {
transition: all 0.2s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
/* Button enhancements */
.el-button {
font-weight: 500;
}
/* Tag enhancements */
.el-tag {
font-weight: 500;
}
/* Card enhancements */
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-card__body {
padding: 20px;
}
/* Table enhancements */
.el-table {
font-size: 14px;
}
.el-table th {
font-weight: 600;
}
/* Empty state */
.el-empty__description {
margin-top: 16px;
font-size: 14px;
}
/* Loading state */
.el-loading-mask {
border-radius: 12px;
}

View File

@@ -1,51 +1,48 @@
/* Dark mode styles */ /* Dark Mode Theme */
html.dark { html.dark {
--el-bg-color: #212121; --el-bg-color: #1e1e2e;
--el-bg-color-page: #181818; --el-bg-color-page: #1a1a2e;
--el-bg-color-overlay: #303030; --el-bg-color-overlay: #27293d;
--el-fill-color-blank: #212121; --el-fill-color-blank: #1e1e2e;
--el-border-color: #404040; background-color: #1a1a2e;
--el-border-color-light: #353535;
--el-border-color-lighter: #2a2a2a;
--el-text-color-primary: #e5e7eb;
--el-text-color-secondary: #888888;
--el-text-color-placeholder: #afafaf;
background-color: #212121;
color-scheme: dark;
} }
/* Scrollbar */ html.dark body {
html.dark ::-webkit-scrollbar { background-color: #1a1a2e;
width: 6px;
height: 6px;
}
html.dark ::-webkit-scrollbar-track {
background: #303030;
}
html.dark ::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 3px;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #505050;
}
/* Form */
html.dark .el-form-item__label {
color: #e5e7eb; color: #e5e7eb;
} }
/* Input */ /* Dark mode scrollbar */
html.dark ::-webkit-scrollbar-track {
background: #27293d;
}
html.dark ::-webkit-scrollbar-thumb {
background: #3a3d5c;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d6c;
}
/* Dark mode cards */
html.dark .el-card {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
}
/* Dark mode inputs */
html.dark .el-input__wrapper { html.dark .el-input__wrapper {
background: var(--color-bg-input); background-color: #27293d;
box-shadow: 0 0 0 1px #404040 inset; box-shadow: 0 0 0 1px #3a3d5c inset;
} }
html.dark .el-input__wrapper:hover { html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #505050 inset; box-shadow: 0 0 0 1px #4a4d6c inset;
} }
html.dark .el-input__wrapper.is-focus { html.dark .el-input__wrapper.is-focus {
@@ -57,44 +54,71 @@ html.dark .el-input__inner {
} }
html.dark .el-input__inner::placeholder { html.dark .el-input__inner::placeholder {
color: #afafaf; color: #6b7280;
} }
/* Dark mode textarea */
html.dark .el-textarea__inner { html.dark .el-textarea__inner {
background: var(--color-bg-input); background-color: #1e1e2d;
box-shadow: 0 0 0 1px #404040 inset; border-color: #3a3d5c;
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-textarea__inner:hover { html.dark .el-textarea__inner::placeholder {
box-shadow: 0 0 0 1px #505050 inset; color: #6b7280;
} }
html.dark .el-textarea__inner:focus { /* Dark mode table */
box-shadow: 0 0 0 1px var(--el-color-primary) inset; html.dark .el-table {
} background-color: #27293d;
/* Select */
html.dark .el-select__wrapper {
background: var(--color-bg-input);
box-shadow: 0 0 0 1px #404040 inset;
}
html.dark .el-select__wrapper:hover {
box-shadow: 0 0 0 1px #505050 inset;
}
html.dark .el-select__selected-item {
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-select__placeholder { html.dark .el-table th.el-table__cell {
color: #afafaf; background-color: #1e1e2e;
color: #e5e7eb;
}
html.dark .el-table tr {
background-color: #27293d;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: #1e1e2e;
}
html.dark .el-table__row:hover > td.el-table__cell {
background-color: #2a2a3c !important;
}
/* Dark mode tags */
html.dark .el-tag--info {
background-color: #3a3d5c;
border-color: #3a3d5c;
color: #e5e7eb;
}
/* Dark mode buttons */
html.dark .el-button--default {
background-color: #27293d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background-color: #2a2a3c;
border-color: #4a4d6c;
color: #fff;
}
/* Dark mode select */
html.dark .el-select .el-input__wrapper {
background-color: #27293d;
} }
html.dark .el-select-dropdown { html.dark .el-select-dropdown {
background: #303030; background-color: #27293d;
border-color: #404040; border-color: #3a3d5c;
} }
html.dark .el-select-dropdown__item { html.dark .el-select-dropdown__item {
@@ -102,92 +126,55 @@ html.dark .el-select-dropdown__item {
} }
html.dark .el-select-dropdown__item:hover { html.dark .el-select-dropdown__item:hover {
background: #3a3a3a; background-color: #2a2a3c;
} }
html.dark .el-select-dropdown__item.is-selected { /* Dark mode dialog */
color: var(--el-color-primary);
}
html.dark .el-select-dropdown__item.is-disabled {
color: #666666;
}
/* Tag */
html.dark .el-tag--info {
background: #303030;
border-color: #404040;
color: #b0b0b0;
}
/* Button */
html.dark .el-button--default {
background: #303030;
border-color: #404040;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background: #3a3a3a;
border-color: #505050;
color: #e5e7eb;
}
/* Card */
html.dark .el-card {
background: #212121;
border-color: #353535;
color: #b0b0b0;
}
html.dark .el-card__header {
border-bottom-color: #353535;
color: #e5e7eb;
}
/* Dialog */
html.dark .el-dialog { html.dark .el-dialog {
background: #212121; background-color: #27293d;
}
html.dark .el-dialog__header {
border-bottom-color: #3a3d5c;
} }
html.dark .el-dialog__title { html.dark .el-dialog__title {
color: #e5e7eb; color: #e5e7eb;
} }
/* Message */ html.dark .el-dialog__body {
html.dark .el-message { color: #e5e7eb;
background: #303030;
border-color: #404040;
} }
html.dark .el-message--success { /* Dark mode message box */
background: #1e3d2e; html.dark .el-message-box {
border-color: #3d6b4f; background-color: #27293d;
border-color: #3a3d5c;
} }
html.dark .el-message--warning { html.dark .el-message-box__title {
background: #3d3020; color: #e5e7eb;
border-color: #6b5020;
} }
html.dark .el-message--error { html.dark .el-message-box__message {
background: #3d2027; color: #e5e7eb;
border-color: #5c2d2d;
} }
/* Loading */ /* Dark mode empty */
html.dark .el-empty__description {
color: #9ca3af;
}
/* Dark mode loading */
html.dark .el-loading-mask { html.dark .el-loading-mask {
background-color: rgba(33, 33, 33, 0.9); background-color: rgba(30, 30, 46, 0.9);
} }
/* Overlay */ html.dark .el-loading-text {
html.dark .el-overlay { color: #e5e7eb;
background-color: rgba(0, 0, 0, 0.6);
} }
/* Tooltip */ /* Dark mode tooltip */
html.dark .el-tooltip__popper { html.dark .el-tooltip__trigger {
background: #303030 !important; color: #e5e7eb;
border-color: #404040 !important;
color: #e5e7eb !important;
} }

View File

@@ -1,117 +0,0 @@
:root {
/* Text colors */
--color-text-primary: #303133;
--color-text-secondary: #606266;
--color-text-muted: #909399;
--color-text-light: #c0c4cc;
--color-text-placeholder: #a8abb2;
/* Background colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9f9f9;
--color-bg-tertiary: #fafafa;
--color-bg-surface: #ffffff;
--color-bg-muted: #f4f4f5;
--color-bg-input: #ffffff;
--color-bg-hover: #efefef;
--color-bg-active: #eaeaea;
/* Border colors */
--color-border: #dcdfe6;
--color-border-light: #e4e7ed;
--color-border-lighter: #ebeef5;
--color-border-extra-light: #f2f6fc;
/* Status colors */
--color-primary: #409eff;
--color-primary-light: #ecf5ff;
--color-success: #67c23a;
--color-warning: #e6a23c;
--color-danger: #f56c6c;
--color-danger-dark: #c45656;
--color-danger-light: #fef0f0;
--color-info: #909399;
/* Button colors */
--color-btn-primary: #303133;
--color-btn-primary-hover: #4a4d5c;
/* Element Plus mapping */
--el-color-primary: var(--color-primary);
--el-color-success: var(--color-success);
--el-color-warning: var(--color-warning);
--el-color-danger: var(--color-danger);
--el-color-info: var(--color-info);
--el-text-color-primary: var(--color-text-primary);
--el-text-color-regular: var(--color-text-secondary);
--el-text-color-secondary: var(--color-text-muted);
--el-text-color-placeholder: var(--color-text-placeholder);
--el-bg-color: var(--color-bg-primary);
--el-bg-color-page: var(--color-bg-secondary);
--el-bg-color-overlay: var(--color-bg-primary);
--el-border-color: var(--color-border);
--el-border-color-light: var(--color-border-light);
--el-border-color-lighter: var(--color-border-lighter);
--el-border-color-extra-light: var(--color-border-extra-light);
--el-fill-color-blank: var(--color-bg-primary);
--el-fill-color-light: var(--color-bg-tertiary);
--el-fill-color: var(--color-bg-tertiary);
--el-fill-color-dark: var(--color-bg-hover);
--el-fill-color-darker: var(--color-bg-active);
/* Input */
--el-input-bg-color: var(--color-bg-input);
--el-input-border-color: var(--color-border);
--el-input-hover-border-color: var(--color-border-light);
/* Dialog */
--el-dialog-bg-color: var(--color-bg-primary);
--el-overlay-color: rgba(0, 0, 0, 0.5);
}
html.dark {
/* Text colors */
--color-text-primary: #e5e7eb;
--color-text-secondary: #b0b0b0;
--color-text-muted: #888888;
--color-text-light: #666666;
--color-text-placeholder: #afafaf;
/* Background colors */
--color-bg-primary: #212121;
--color-bg-secondary: #181818;
--color-bg-tertiary: #303030;
--color-bg-surface: #303030;
--color-bg-muted: #303030;
--color-bg-input: #2f2f2f;
--color-bg-hover: #3a3a3a;
--color-bg-active: #454545;
/* Border colors */
--color-border: #404040;
--color-border-light: #353535;
--color-border-lighter: #2a2a2a;
--color-border-extra-light: #222222;
/* Status colors */
--color-primary: #409eff;
--color-danger: #f87171;
--color-danger-dark: #f87171;
--color-danger-light: #3d2027;
--color-info: #888888;
/* Button colors */
--color-btn-primary: #404040;
--color-btn-primary-hover: #505050;
/* Dark overrides */
--el-text-color-regular: var(--color-text-primary);
--el-overlay-color: rgba(0, 0, 0, 0.7);
background-color: #181818;
color-scheme: dark;
}

View File

@@ -1,249 +0,0 @@
<template>
<!-- Edit mode: use el-form-item for validation -->
<el-form-item v-if="!readonly" :label="label" :prop="prop" :class="($attrs.class as string)">
<!-- text -->
<el-input
v-if="type === 'text'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- number -->
<el-input
v-else-if="type === 'number'"
:model-value="modelValue != null ? String(modelValue) : ''"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="handleNumberInput($event)"
/>
<!-- switch -->
<div v-else-if="type === 'switch'" class="config-field-switch-wrap">
<el-switch
:model-value="modelValue"
:disabled="disabled"
size="small"
@update:model-value="$emit('update:modelValue', $event)"
/>
<span v-if="tip" class="config-field-switch-tip">{{ tip }}</span>
</div>
<!-- select -->
<PopoverMenu
v-else-if="type === 'select'"
:model-value="modelValue"
:display-value="selectDisplayValue"
:disabled="disabled"
:width="selectWidth"
selectable
full-width
filterable
:filter-placeholder="placeholder || 'Select...'"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #default="{ filterText }">
<PopoverMenuItem
v-for="opt in filteredOptions(filterText)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
<!-- password -->
<el-input
v-else-if="type === 'password'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
type="password"
show-password
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- kv -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- tags (string array) -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<div v-if="tip && type !== 'switch'" class="config-field-tip">{{ tip }}</div>
</el-form-item>
<!-- Readonly mode: plain display -->
<div v-else class="config-field-readonly" :class="($attrs.class as string)">
<div class="config-field-label">{{ label }}</div>
<!-- switch readonly -->
<el-switch
v-if="type === 'switch'"
:model-value="modelValue"
disabled
size="small"
/>
<!-- kv readonly -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue || []"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
readonly
/>
<!-- tags readonly -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
readonly
/>
<!-- text/number/select/password readonly -->
<el-input
v-else
:model-value="displayValue"
disabled
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import KeyValueEditor from './KeyValueEditor.vue'
import StringListEditor from './StringListEditor.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
const props = withDefaults(
defineProps<{
label: string
type?: 'text' | 'number' | 'switch' | 'select' | 'password' | 'kv' | 'tags'
readonly?: boolean
modelValue?: any
placeholder?: string
disabled?: boolean
tip?: string
prop?: string
options?: Array<{ label: string; value: string | number }>
min?: number
max?: number
keyPlaceholder?: string
valuePlaceholder?: string
}>(),
{
type: 'text',
readonly: false,
modelValue: undefined,
placeholder: '',
disabled: false,
tip: '',
prop: '',
options: () => [],
min: undefined,
max: undefined,
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
},
)
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const handleNumberInput = (val: string) => {
if (val === '') {
emit('update:modelValue', undefined)
return
}
const num = Number(val)
if (!isNaN(num)) {
let clamped = num
if (props.min != null && clamped < props.min) clamped = props.min
if (props.max != null && clamped > props.max) clamped = props.max
emit('update:modelValue', clamped)
}
}
const selectDisplayValue = computed(() => {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : ''
})
const selectWidth = computed(() => {
return Math.max(160, ...props.options.map((o) => o.label.length * 10 + 60))
})
const filteredOptions = (filterText: string) => {
if (!filterText) return props.options
const lower = filterText.toLowerCase()
return props.options.filter((o) => o.label.toLowerCase().includes(lower))
}
const displayValue = computed(() => {
if (props.modelValue == null || props.modelValue === '') return '—'
if (props.type === 'select') {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : String(props.modelValue)
}
if (props.type === 'password') {
return props.modelValue ? '••••••' : '—'
}
return String(props.modelValue)
})
</script>
<style scoped>
.config-field-switch-wrap {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
width: 100%;
}
.config-field-switch-tip {
font-size: 12px;
color: var(--color-text-muted);
}
.config-field-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.config-field-readonly {
margin-bottom: 16px;
}
.config-field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
line-height: 1;
}
.config-field-readonly :deep(*) {
cursor: default !important;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__wrapper) {
background: var(--color-bg-tertiary);
box-shadow: 0 0 0 1px var(--color-border-lighter) inset;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__inner) {
color: var(--color-text-primary);
-webkit-text-fill-color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.config-field-readonly :deep(.el-switch.is-disabled) {
opacity: 1;
}
</style>

View File

@@ -1,185 +0,0 @@
<template>
<div class="config-section-card">
<!-- Collapsible: header is a separate clickable area -->
<template v-if="collapsible">
<div
v-if="title"
class="section-header clickable"
@click="handleToggle"
>
<h3 class="section-title">{{ title }}</h3>
<div class="section-header-right">
<span v-if="readonly && !hasValue" class="not-configured-badge">
Not configured
</span>
<el-icon v-if="canToggle" class="collapse-arrow" :class="{ expanded }">
<ArrowDown />
</el-icon>
</div>
</div>
<div class="collapse-wrapper" :class="{ expanded }">
<div class="collapse-inner">
<div class="section-body">
<slot />
</div>
</div>
</div>
</template>
<!-- Non-collapsible: title and content in one area -->
<template v-else>
<div class="section-body">
<h3 v-if="title" class="section-title section-title-inline">{{ title }}</h3>
<slot />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
const props = withDefaults(
defineProps<{
title?: string
collapsible?: boolean
readonly?: boolean
hasValue?: boolean
}>(),
{
title: '',
collapsible: false,
readonly: false,
hasValue: true,
},
)
const computeInitial = () => {
if (!props.collapsible) return true
return props.hasValue
}
const expanded = ref(computeInitial())
// Only auto-expand when hasValue goes from false to true (async data loaded)
// Never auto-collapse — don't override user interaction
watch(
() => props.hasValue,
(newVal, oldVal) => {
if (newVal && !oldVal && props.collapsible) {
expanded.value = true
}
},
)
const canToggle = computed(() => {
if (!props.collapsible) return false
if (props.readonly && !props.hasValue) return false
return true
})
const handleToggle = () => {
if (canToggle.value) {
expanded.value = !expanded.value
}
}
</script>
<style scoped lang="scss">
.config-section-card {
background: var(--el-bg-color);
border: 1px solid var(--color-border-lighter);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
/* Collapsible header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.section-header.clickable {
cursor: pointer;
transition: background 0.15s;
}
.section-header.clickable:hover {
background: var(--color-bg-hover);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
/* Inline title for non-collapsible sections */
.section-title-inline {
margin-bottom: 16px;
}
.section-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.not-configured-badge {
font-size: 11px;
color: var(--color-text-light);
background: var(--color-bg-muted);
padding: 2px 8px;
border-radius: 4px;
}
.collapse-arrow {
transition: transform 0.3s;
color: var(--color-text-muted);
}
.collapse-arrow.expanded {
transform: rotate(-180deg);
}
/* Grid-based collapse animation */
.collapse-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.collapse-wrapper.expanded {
grid-template-rows: 1fr;
}
.collapse-inner {
overflow: hidden;
}
.section-body {
padding: 20px 20px 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-body :deep(.el-form-item) {
margin-bottom: 0;
}
.section-body :deep(.config-field-readonly) {
margin-bottom: 0;
}
@include mobile {
.section-body {
padding: 16px;
}
}
</style>

View File

@@ -1,51 +1,42 @@
<template> <template>
<div class="kv-editor"> <div class="kv-editor">
<template v-if="readonly"> <div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<div v-if="modelValue.length === 0" class="kv-empty"></div> <el-input
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row"> :model-value="entry.key"
<span class="kv-readonly-key">{{ entry.key }}</span> :placeholder="keyPlaceholder"
<span class="kv-readonly-value">{{ entry.value }}</span> class="kv-input"
</div> @update:model-value="updateEntry(index, 'key', $event)"
</template> />
<template v-else> <el-input
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row"> :model-value="entry.value"
<el-input :placeholder="valuePlaceholder"
:model-value="entry.key" class="kv-input"
:placeholder="keyPlaceholder" @update:model-value="updateEntry(index, 'value', $event)"
class="kv-input" />
@update:model-value="updateEntry(index, 'key', $event)" <button class="kv-remove-btn" @click="removeEntry(index)">
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
Add
</button> </button>
</template> </div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
fill="currentColor"
/>
</svg>
Add
</button>
</div> </div>
</template> </template>
@@ -59,13 +50,11 @@ interface Props {
modelValue: KVEntry[] modelValue: KVEntry[]
keyPlaceholder?: string keyPlaceholder?: string
valuePlaceholder?: string valuePlaceholder?: string
readonly?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
keyPlaceholder: 'Key', keyPlaceholder: 'Key',
valuePlaceholder: 'Value', valuePlaceholder: 'Value',
readonly: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -140,45 +129,25 @@ html.dark .kv-remove-btn:hover {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 12px; padding: 6px 14px;
border: 1px solid var(--color-border); border: 1px dashed var(--el-border-color);
border-radius: 6px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--color-text-secondary); color: var(--el-text-color-secondary);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.2s;
align-self: flex-start; align-self: flex-start;
} }
.kv-add-btn svg { .kv-add-btn svg {
width: 13px; width: 14px;
height: 13px; height: 14px;
} }
.kv-add-btn:hover { .kv-add-btn:hover {
background: var(--color-bg-hover); color: var(--el-color-primary);
} border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
.kv-empty {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.kv-readonly-row {
display: flex;
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.kv-readonly-key {
color: var(--el-text-color-secondary);
min-width: 100px;
}
.kv-readonly-value {
color: var(--el-text-color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
} }
</style> </style>

View File

@@ -1,49 +1,104 @@
<template> <template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }" @click="$emit('click', proxy)"> <div
class="proxy-card"
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
>
<div class="card-main"> <div class="card-main">
<div class="card-left"> <div class="card-left">
<div class="card-header"> <div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span> <span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span> <span class="type-tag" :class="`type-${proxy.type}`">{{
<span class="status-pill" :class="statusClass"> proxy.type.toUpperCase()
<span class="status-dot"></span> }}</span>
{{ proxy.status }} <span v-if="isStore" class="source-tag">
<svg
class="store-icon"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
fill="currentColor"
/>
<path
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
fill="currentColor"
/>
</svg>
Store
</span> </span>
</div> </div>
<div class="card-address">
<template v-if="proxy.remote_addr && localDisplay"> <div class="card-meta">
{{ proxy.remote_addr }} {{ localDisplay }} <span v-if="proxy.local_addr" class="meta-item">
</template> <span class="meta-label">Local</span>
<template v-else-if="proxy.remote_addr">{{ proxy.remote_addr }}</template> <span class="meta-value code">{{ proxy.local_addr }}</span>
<template v-else-if="localDisplay">{{ localDisplay }}</template> </span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div> </div>
</div> </div>
<div class="card-right"> <div class="card-right">
<span v-if="showSource" class="source-label">{{ displaySource }}</span> <div v-if="proxy.err" class="error-info">
<div v-if="showActions" @click.stop> <el-tooltip :content="proxy.err" placement="top" :show-after="300">
<PopoverMenu :width="120" placement="bottom-end"> <div class="error-badge">
<template #trigger> <el-icon class="error-icon"><Warning /></el-icon>
<ActionButton variant="outline" size="small"> <span class="error-text">Error</span>
<el-icon><MoreFilled /></el-icon> </div>
</ActionButton> </el-tooltip>
</template> </div>
<PopoverMenuItem v-if="proxy.status === 'disabled'" @click="$emit('toggle', proxy, true)">
<el-icon><Open /></el-icon> <div class="status-badge" :class="statusClass">
Enable <span class="status-dot"></span>
</PopoverMenuItem> {{ proxy.status }}
<PopoverMenuItem v-else @click="$emit('toggle', proxy, false)"> </div>
<el-icon><TurnOff /></el-icon>
Disable <!-- Store actions -->
</PopoverMenuItem> <div v-if="isStore" class="card-actions">
<PopoverMenuItem @click="$emit('edit', proxy)"> <button
<el-icon><Edit /></el-icon> class="action-btn edit-btn"
Edit @click.stop="$emit('edit', proxy)"
</PopoverMenuItem> >
<PopoverMenuItem danger @click="$emit('delete', proxy)"> <svg
<el-icon><Delete /></el-icon> viewBox="0 0 16 16"
Delete fill="none"
</PopoverMenuItem> xmlns="http://www.w3.org/2000/svg"
</PopoverMenu> >
<path
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
fill="currentColor"
/>
</svg>
</button>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,40 +107,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue' import { Warning } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue' import type { ProxyStatus } from '../types/proxy'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import type { ProxyStatus } from '../types'
interface Props { interface Props {
proxy: ProxyStatus proxy: ProxyStatus
showSource?: boolean
showActions?: boolean
deleting?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>()
showSource: false,
showActions: false,
deleting: false,
})
defineEmits<{ defineEmits<{
click: [proxy: ProxyStatus]
edit: [proxy: ProxyStatus] edit: [proxy: ProxyStatus]
delete: [proxy: ProxyStatus] delete: [proxy: ProxyStatus]
toggle: [proxy: ProxyStatus, enabled: boolean]
}>() }>()
const displaySource = computed(() => { const isStore = computed(() => props.proxy.source === 'store')
return props.proxy.source === 'store' ? 'store' : 'config'
})
const localDisplay = computed(() => {
if (props.proxy.plugin) return `plugin:${props.proxy.plugin}`
return props.proxy.local_addr || ''
})
const statusClass = computed(() => { const statusClass = computed(() => {
switch (props.proxy.status) { switch (props.proxy.status) {
@@ -93,43 +129,53 @@ const statusClass = computed(() => {
return 'running' return 'running'
case 'error': case 'error':
return 'error' return 'error'
case 'disabled':
return 'disabled'
default: default:
return 'waiting' return 'waiting'
} }
}) })
</script> </script>
<style scoped lang="scss"> <style scoped>
.proxy-card { .proxy-card {
background: $color-bg-primary; position: relative;
border: 1px solid $color-border-lighter; display: block;
border-radius: $radius-md; background: var(--el-bg-color);
padding: 14px 20px; border: 1px solid var(--el-border-color-lighter);
cursor: pointer; border-radius: 12px;
transition: all $transition-medium; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
&:hover { .proxy-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); border-color: var(--el-border-color);
border-color: $color-border; box-shadow:
} 0 4px 16px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
}
&.has-error { .proxy-card.has-error {
border-color: rgba(245, 108, 108, 0.3); border-color: var(--el-color-danger-light-5);
} }
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
} }
.card-main { .card-main {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: $spacing-lg; padding: 18px 20px;
gap: 20px;
min-height: 76px;
} }
/* Left Section */
.card-left { .card-left {
@include flex-column; display: flex;
gap: $spacing-sm; flex-direction: column;
justify-content: center;
gap: 10px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -137,68 +183,311 @@ const statusClass = computed(() => {
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-sm; gap: 8px;
flex-wrap: wrap;
} }
.proxy-name { .proxy-name {
font-size: $font-size-lg; font-size: 15px;
font-weight: $font-weight-semibold; font-weight: 600;
color: $color-text-primary; color: var(--el-text-color-primary);
line-height: 1.3;
letter-spacing: -0.01em;
} }
.type-tag { .type-tag {
font-size: $font-size-xs; font-size: 10px;
font-weight: $font-weight-medium; font-weight: 600;
padding: 2px 8px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
background: $color-bg-muted; background: var(--el-fill-color);
color: $color-text-secondary; color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.card-address { .type-tag.type-tcp {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: rgba(59, 130, 246, 0.1);
font-size: $font-size-sm; color: #3b82f6;
color: $color-text-muted; }
.type-tag.type-udp {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.type-tag.type-http {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.type-tag.type-https {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
.type-tag.type-stcp,
.type-tag.type-sudp,
.type-tag.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-tag.type-tcpmux {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .type-tag.type-tcp {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .type-tag.type-udp {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
html.dark .type-tag.type-http {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
html.dark .type-tag.type-https {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
}
html.dark .type-tag.type-stcp,
html.dark .type-tag.type-sudp,
html.dark .type-tag.type-xtcp {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
html.dark .type-tag.type-tcpmux {
background: rgba(244, 114, 182, 0.15);
color: #f472b6;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1) 0%,
rgba(118, 75, 162, 0.1) 100%
);
color: #764ba2;
}
html.dark .source-tag {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.15) 0%,
rgba(167, 139, 250, 0.15) 100%
);
color: #a78bfa;
}
.store-icon {
width: 12px;
height: 12px;
}
.card-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-sm; gap: 16px;
flex-wrap: wrap;
} }
.meta-item {
display: flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 500;
}
.meta-value {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 3px 7px;
border-radius: 5px;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Right Section */
.card-right { .card-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
.source-label { .error-badge {
font-size: $font-size-xs; display: flex;
color: $color-text-light; align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: var(--el-color-danger-light-9);
cursor: help;
} }
.error-icon {
color: var(--el-color-danger);
font-size: 14px;
}
.error-text {
font-size: 11px;
font-weight: 500;
color: var(--el-color-danger);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-dot { .status-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: currentColor; flex-shrink: 0;
} }
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.running .status-dot {
background: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
animation: pulse 2s infinite;
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.error .status-dot {
background: var(--el-color-danger);
}
@include mobile { .status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
.status-badge.waiting .status-dot {
background: var(--el-color-warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action buttons */
.card-actions {
display: flex;
gap: 4px;
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card:hover .card-actions {
display: flex;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn svg {
width: 14px;
height: 14px;
}
.action-btn:hover {
transform: scale(1.05);
}
.edit-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .edit-btn:hover {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .delete-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main { .card-main {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: $spacing-sm; gap: 14px;
padding: 14px 16px;
} }
.card-right { .card-right {
flex-direction: row;
align-items: center;
justify-content: space-between; justify-content: space-between;
} border-top: 1px solid var(--el-border-color-lighter);
.card-address { padding-top: 14px;
word-break: break-all;
} }
} }
</style> </style>

View File

@@ -0,0 +1,202 @@
<template>
<el-card
class="stat-card"
:class="{ clickable: !!to }"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<div class="stat-card-content">
<div class="stat-icon" :class="`icon-${type}`">
<component :is="iconComponent" class="icon" />
</div>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Connection,
CircleCheck,
Warning,
Setting,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'proxies' | 'running' | 'error' | 'config'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'proxies',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'proxies':
return Connection
case 'running':
return CircleCheck
case 'error':
return Warning
case 'config':
return Setting
default:
return Connection
}
})
const handleClick = () => {
if (props.to) {
router.push(props.to)
}
}
</script>
<style scoped>
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.stat-card.clickable:hover .arrow-icon {
transform: translateX(4px);
}
html.dark .stat-card {
border-color: #3a3d5c;
background: #27293d;
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.arrow-icon {
color: #909399;
font-size: 18px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
html.dark .arrow-icon {
color: #9ca3af;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon .icon {
width: 28px;
height: 28px;
}
.icon-proxies {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-running {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.icon-error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-config {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-running {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .icon-error {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-config {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
html.dark .stat-value {
color: #e5e7eb;
}
.stat-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
html.dark .stat-label {
color: #9ca3af;
}
.stat-subtitle {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
font-size: 12px;
color: #909399;
}
html.dark .stat-subtitle {
border-top-color: #3a3d5c;
color: #9ca3af;
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div class="status-pills">
<button
v-for="pill in pills"
:key="pill.status"
class="pill"
:class="{ active: modelValue === pill.status, [pill.status || 'all']: true }"
@click="emit('update:modelValue', pill.status)"
>
{{ pill.label }} {{ pill.count }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
items: Array<{ status: string }>
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const pills = computed(() => {
const counts = { running: 0, error: 0, waiting: 0 }
for (const item of props.items) {
const s = item.status as keyof typeof counts
if (s in counts) {
counts[s]++
}
}
return [
{ status: '', label: 'All', count: props.items.length },
{ status: 'running', label: 'Running', count: counts.running },
{ status: 'error', label: 'Error', count: counts.error },
{ status: 'waiting', label: 'Waiting', count: counts.waiting },
]
})
</script>
<style scoped lang="scss">
.status-pills {
display: flex;
gap: $spacing-sm;
}
.pill {
border: none;
border-radius: 12px;
padding: $spacing-xs $spacing-md;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
cursor: pointer;
background: $color-bg-muted;
color: $color-text-secondary;
transition: all $transition-fast;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
&.active {
&.all {
background: $color-bg-muted;
color: $color-text-secondary;
}
&.running {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.error {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&.waiting {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
}
}
@include mobile {
.status-pills {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
</style>

View File

@@ -1,141 +0,0 @@
<template>
<div class="string-list-editor">
<template v-if="readonly">
<div v-if="!modelValue || modelValue.length === 0" class="list-empty"></div>
<div v-for="(item, index) in modelValue" :key="index" class="list-readonly-item">
{{ item }}
</div>
</template>
<template v-else>
<div v-for="(item, index) in modelValue" :key="index" class="item-row">
<el-input
:model-value="item"
:placeholder="placeholder"
@update:model-value="updateItem(index, $event)"
/>
<button class="item-remove" @click="removeItem(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" fill="currentColor"/>
</svg>
</button>
</div>
<button class="list-add-btn" @click="addItem">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" fill="currentColor"/>
</svg>
Add
</button>
</template>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue: string[]
placeholder?: string
readonly?: boolean
}>(),
{
placeholder: 'Enter value',
readonly: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const addItem = () => {
emit('update:modelValue', [...(props.modelValue || []), ''])
}
const removeItem = (index: number) => {
const newValue = [...props.modelValue]
newValue.splice(index, 1)
emit('update:modelValue', newValue)
}
const updateItem = (index: number, value: string) => {
const newValue = [...props.modelValue]
newValue[index] = value
emit('update:modelValue', newValue)
}
</script>
<style scoped>
.string-list-editor {
width: 100%;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-row .el-input {
flex: 1;
}
.item-remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.item-remove svg {
width: 14px;
height: 14px;
}
.item-remove:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.list-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.list-add-btn svg {
width: 13px;
height: 13px;
}
.list-add-btn:hover {
background: var(--color-bg-hover);
}
.list-empty {
color: var(--color-text-muted);
font-size: 13px;
}
.list-readonly-item {
font-size: 13px;
color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 2px 0;
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<ConfigSection title="Authentication" :readonly="readonly">
<template v-if="['http', 'tcpmux'].includes(form.type)">
<div class="field-row three-col">
<ConfigField label="HTTP User" type="text" v-model="form.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.httpPassword" :readonly="readonly" />
<ConfigField label="Route By HTTP User" type="text" v-model="form.routeByHTTPUser" :readonly="readonly" />
</div>
</template>
<template v-if="['stcp', 'sudp', 'xtcp'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Secret Key" type="password" v-model="form.secretKey" prop="secretKey" :readonly="readonly" />
<ConfigField label="Allow Users" type="tags" v-model="form.allowUsers" placeholder="username" :readonly="readonly" />
</div>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,149 +0,0 @@
<template>
<!-- Backend Mode -->
<template v-if="!readonly">
<el-form-item label="Backend Mode">
<el-radio-group v-model="backendMode">
<el-radio value="direct">Direct</el-radio>
<el-radio value="plugin">Plugin</el-radio>
</el-radio-group>
</el-form-item>
</template>
<!-- Direct mode -->
<template v-if="backendMode === 'direct'">
<div class="field-row two-col">
<ConfigField label="Local IP" type="text" v-model="form.localIP" placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Local Port" type="number" v-model="form.localPort" :min="0" :max="65535" prop="localPort" :readonly="readonly" />
</div>
</template>
<!-- Plugin mode -->
<template v-else>
<div class="field-row two-col">
<ConfigField label="Plugin Type" type="select" v-model="form.pluginType"
:options="PLUGIN_LIST.map((p) => ({ label: p, value: p }))" :readonly="readonly" />
<div></div>
</div>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Local Address" type="text" v-model="form.pluginConfig.localAddr" placeholder="127.0.0.1:8080" :readonly="readonly" />
<ConfigField v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)"
label="Host Header Rewrite" type="text" v-model="form.pluginConfig.hostHeaderRewrite" :readonly="readonly" />
<div v-else></div>
</div>
</template>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)">
<ConfigField label="Request Headers" type="kv" v-model="pluginRequestHeaders"
key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
<template v-if="['https2http', 'https2https', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Certificate Path" type="text" v-model="form.pluginConfig.crtPath" placeholder="/path/to/cert.pem" :readonly="readonly" />
<ConfigField label="Key Path" type="text" v-model="form.pluginConfig.keyPath" placeholder="/path/to/key.pem" :readonly="readonly" />
</div>
</template>
<template v-if="['https2http', 'https2https'].includes(form.pluginType)">
<ConfigField label="Enable HTTP/2" type="switch" v-model="form.pluginConfig.enableHTTP2" :readonly="readonly" />
</template>
<template v-if="form.pluginType === 'http_proxy'">
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'socks5'">
<div class="field-row two-col">
<ConfigField label="Username" type="text" v-model="form.pluginConfig.username" :readonly="readonly" />
<ConfigField label="Password" type="password" v-model="form.pluginConfig.password" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'static_file'">
<div class="field-row two-col">
<ConfigField label="Local Path" type="text" v-model="form.pluginConfig.localPath" placeholder="/path/to/files" :readonly="readonly" />
<ConfigField label="Strip Prefix" type="text" v-model="form.pluginConfig.stripPrefix" :readonly="readonly" />
</div>
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'unix_domain_socket'">
<ConfigField label="Unix Socket Path" type="text" v-model="form.pluginConfig.unixPath" placeholder="/tmp/socket.sock" :readonly="readonly" />
</template>
</template>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const PLUGIN_LIST = [
'http2https', 'http_proxy', 'https2http', 'https2https', 'http2http',
'socks5', 'static_file', 'unix_domain_socket', 'tls2raw', 'virtual_net',
]
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const backendMode = ref<'direct' | 'plugin'>(form.value.pluginType ? 'plugin' : 'direct')
const isHydrating = ref(false)
const pluginRequestHeaders = computed({
get() {
const set = form.value.pluginConfig?.requestHeaders?.set
if (!set || typeof set !== 'object') return []
return Object.entries(set).map(([key, value]) => ({ key, value: String(value) }))
},
set(val: Array<{ key: string; value: string }>) {
if (!form.value.pluginConfig) form.value.pluginConfig = {}
if (val.length === 0) {
delete form.value.pluginConfig.requestHeaders
} else {
form.value.pluginConfig.requestHeaders = {
set: Object.fromEntries(val.map((e) => [e.key, e.value])),
}
}
},
})
watch(() => form.value.pluginType, (newType, oldType) => {
if (isHydrating.value) return
if (!oldType || !newType || newType === oldType) return
if (form.value.pluginConfig && Object.keys(form.value.pluginConfig).length > 0) {
form.value.pluginConfig = {}
}
})
watch(backendMode, (mode) => {
if (mode === 'direct') {
form.value.pluginType = ''
form.value.pluginConfig = {}
} else if (!form.value.pluginType) {
form.value.pluginType = 'http2https'
}
})
const hydrate = () => {
isHydrating.value = true
backendMode.value = form.value.pluginType ? 'plugin' : 'direct'
nextTick(() => { isHydrating.value = false })
}
watch(() => props.modelValue, () => { hydrate() })
onMounted(() => { hydrate() })
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,51 +0,0 @@
<template>
<!-- Name / Type / Enabled -->
<div v-if="!readonly" class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="editing || readonly"
placeholder="my-proxy"
/>
</el-form-item>
<ConfigField
label="Type"
type="select"
v-model="form.type"
:disabled="editing"
:options="PROXY_TYPES.map((t) => ({ label: t.toUpperCase(), value: t }))"
prop="type"
/>
<el-form-item label="Enabled" class="switch-field">
<el-switch v-model="form.enabled" size="small" />
</el-form-item>
</div>
<div v-else class="field-row three-col">
<ConfigField label="Name" type="text" :model-value="form.name" readonly class="field-grow" />
<ConfigField label="Type" type="text" :model-value="form.type.toUpperCase()" readonly />
<ConfigField label="Enabled" type="switch" :model-value="form.enabled" readonly />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { PROXY_TYPES, type ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,50 +0,0 @@
<template>
<div class="proxy-form-layout">
<ConfigSection :readonly="readonly">
<ProxyBaseSection v-model="form" :readonly="readonly" :editing="editing" />
<ProxyRemoteSection
v-if="['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyBackendSection v-model="form" :readonly="readonly" />
</ConfigSection>
<ProxyAuthSection
v-if="['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyHttpSection v-if="form.type === 'http'" v-model="form" :readonly="readonly" />
<ProxyTransportSection v-model="form" :readonly="readonly" />
<ProxyHealthSection v-model="form" :readonly="readonly" />
<ProxyLoadBalanceSection v-model="form" :readonly="readonly" />
<ProxyNatSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
<ProxyMetadataSection v-model="form" :readonly="readonly" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ProxyBaseSection from './ProxyBaseSection.vue'
import ProxyRemoteSection from './ProxyRemoteSection.vue'
import ProxyBackendSection from './ProxyBackendSection.vue'
import ProxyAuthSection from './ProxyAuthSection.vue'
import ProxyHttpSection from './ProxyHttpSection.vue'
import ProxyTransportSection from './ProxyTransportSection.vue'
import ProxyHealthSection from './ProxyHealthSection.vue'
import ProxyLoadBalanceSection from './ProxyLoadBalanceSection.vue'
import ProxyNatSection from './ProxyNatSection.vue'
import ProxyMetadataSection from './ProxyMetadataSection.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>

View File

@@ -1,52 +0,0 @@
<template>
<ConfigSection title="Health Check" collapsible :readonly="readonly" :has-value="!!form.healthCheckType">
<div class="field-row two-col">
<ConfigField label="Type" type="select" v-model="form.healthCheckType"
:options="[{ label: 'Disabled', value: '' }, { label: 'TCP', value: 'tcp' }, { label: 'HTTP', value: 'http' }]" :readonly="readonly" />
<div></div>
</div>
<template v-if="form.healthCheckType">
<div class="field-row three-col">
<ConfigField label="Timeout (s)" type="number" v-model="form.healthCheckTimeoutSeconds" :min="1" :readonly="readonly" />
<ConfigField label="Max Failed" type="number" v-model="form.healthCheckMaxFailed" :min="1" :readonly="readonly" />
<ConfigField label="Interval (s)" type="number" v-model="form.healthCheckIntervalSeconds" :min="1" :readonly="readonly" />
</div>
<template v-if="form.healthCheckType === 'http'">
<ConfigField label="Path" type="text" v-model="form.healthCheckPath" prop="healthCheckPath" placeholder="/health" :readonly="readonly" />
<ConfigField label="HTTP Headers" type="kv" v-model="healthCheckHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const healthCheckHeaders = computed({
get() {
return form.value.healthCheckHTTPHeaders.map((h) => ({ key: h.name, value: h.value }))
},
set(val: Array<{ key: string; value: string }>) {
form.value.healthCheckHTTPHeaders = val.map((h) => ({ name: h.key, value: h.value }))
},
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

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