mirror of
https://github.com/fatedier/frp.git
synced 2026-04-14 04:59:11 +08:00
Compare commits
2 Commits
dev
...
8d1ab7d585
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1ab7d585 | ||
|
|
f32bec9f4d |
14
.github/workflows/build-and-push-image.yml
vendored
14
.github/workflows/build-and-push-image.yml
vendored
@@ -19,15 +19,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# environment
|
# environment
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@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
|
||||||
|
|||||||
6
.github/workflows/golangci-lint.yml
vendored
6
.github/workflows/golangci-lint.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@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)
|
||||||
|
|||||||
8
.github/workflows/goreleaser.yml
vendored
8
.github/workflows/goreleaser.yml
vendored
@@ -8,15 +8,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@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
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
actions: write
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@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
6
.gitignore
vendored
@@ -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/
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
39
AGENTS.md
39
AGENTS.md
@@ -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
|
|
||||||
40
README.md
40
README.md
@@ -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">
|

|
||||||
<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.
|
||||||
|
|||||||
20
README_zh.md
20
README_zh.md
@@ -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 ?
|
||||||
|
|||||||
10
Release.md
10
Release.md
@@ -1,3 +1,9 @@
|
|||||||
## Fixes
|
## Features
|
||||||
|
|
||||||
* Fixed a configuration-dependent authentication bypass in `type = "http"` proxies when `routeByHTTPUser` is used together with `httpUser` / `httpPassword`. This affected proxy-style requests. Proxy-style authentication failures now return `407 Proxy Authentication Required`.
|
* 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
BIN
doc/pic/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
32
go.mod
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.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
105
go.sum
@@ -1,6 +1,6 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
github.com/Azure/go-ntlmssp v0.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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "; "))
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.68.1"
|
var version = "0.68.0"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -187,25 +187,16 @@ func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byE
|
|||||||
return nil, fmt.Errorf("%v: %s %s %s", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
|
return nil, fmt.Errorf("%v: %s %s %s", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
|
func (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, passwd string) bool {
|
||||||
if rc == nil {
|
vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
|
||||||
return true
|
if ok {
|
||||||
}
|
checkUser := vr.payload.(*RouteConfig).Username
|
||||||
if rc.Username == "" && rc.Password == "" {
|
checkPasswd := vr.payload.(*RouteConfig).Password
|
||||||
return true
|
if (checkUser != "" || checkPasswd != "") && (checkUser != user || checkPasswd != passwd) {
|
||||||
}
|
|
||||||
|
|
||||||
if req.URL.Host != "" {
|
|
||||||
proxyAuth := req.Header.Get("Proxy-Authorization")
|
|
||||||
if proxyAuth == "" {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
user, passwd, ok := httppkg.ParseBasicAuth(proxyAuth)
|
|
||||||
return ok && user == rc.Username && passwd == rc.Password
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
user, passwd, ok := req.BasicAuth()
|
|
||||||
return ok && user == rc.Username && passwd == rc.Password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVhost tries to get vhost router by route policy.
|
// getVhost tries to get vhost router by route policy.
|
||||||
@@ -275,25 +266,18 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
|
|||||||
go libio.Join(remote, client)
|
go libio.Join(remote, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRequestRouteUser(req *http.Request) string {
|
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
|
||||||
|
user := ""
|
||||||
|
// If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header.
|
||||||
if req.URL.Host != "" {
|
if req.URL.Host != "" {
|
||||||
proxyAuth := req.Header.Get("Proxy-Authorization")
|
proxyAuth := req.Header.Get("Proxy-Authorization")
|
||||||
if proxyAuth == "" {
|
if proxyAuth != "" {
|
||||||
// Preserve legacy proxy-mode routing when clients send only Authorization,
|
user, _, _ = httppkg.ParseBasicAuth(proxyAuth)
|
||||||
// so requests still hit the matched route and return 407 instead of 404.
|
|
||||||
// Auth validation intentionally does not share this fallback.
|
|
||||||
user, _, _ := req.BasicAuth()
|
|
||||||
return user
|
|
||||||
}
|
}
|
||||||
user, _, _ := httppkg.ParseBasicAuth(proxyAuth)
|
|
||||||
return user
|
|
||||||
}
|
}
|
||||||
user, _, _ := req.BasicAuth()
|
if user == "" {
|
||||||
return user
|
user, _, _ = req.BasicAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
|
|
||||||
user := getRequestRouteUser(req)
|
|
||||||
|
|
||||||
reqRouteInfo := &RequestRouteInfo{
|
reqRouteInfo := &RequestRouteInfo{
|
||||||
URL: req.URL.Path,
|
URL: req.URL.Path,
|
||||||
@@ -313,19 +297,16 @@ func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
newreq := rp.injectRequestInfoToCtx(req)
|
domain, _ := httppkg.CanonicalHost(req.Host)
|
||||||
rc := newreq.Context().Value(RouteConfigKey).(*RouteConfig)
|
location := req.URL.Path
|
||||||
if !checkRouteAuthByRequest(req, rc) {
|
user, passwd, _ := req.BasicAuth()
|
||||||
if req.URL.Host != "" {
|
if !rp.CheckAuth(domain, location, user, user, passwd) {
|
||||||
rw.Header().Set("Proxy-Authenticate", `Basic realm="Restricted"`)
|
|
||||||
http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired)
|
|
||||||
} else {
|
|
||||||
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||||
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newreq := rp.injectRequestInfoToCtx(req)
|
||||||
if req.Method == http.MethodConnect {
|
if req.Method == http.MethodConnect {
|
||||||
rp.connectHandler(rw, newreq)
|
rp.connectHandler(rw, newreq)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package vhost
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCheckRouteAuthByRequest(t *testing.T) {
|
|
||||||
rc := &RouteConfig{
|
|
||||||
Username: "alice",
|
|
||||||
Password: "secret",
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("accepts nil route config", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
require.True(t, checkRouteAuthByRequest(req, nil))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("accepts route without credentials", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
require.True(t, checkRouteAuthByRequest(req, &RouteConfig{}))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("accepts authorization header", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.SetBasicAuth("alice", "secret")
|
|
||||||
require.True(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("accepts proxy authorization header", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
|
||||||
require.True(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects authorization fallback for proxy request", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.SetBasicAuth("alice", "secret")
|
|
||||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects wrong proxy authorization even when authorization matches", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.SetBasicAuth("alice", "secret")
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
|
||||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects when neither header matches", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.SetBasicAuth("alice", "wrong")
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
|
||||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects proxy authorization on direct request", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
|
||||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRequestRouteUser(t *testing.T) {
|
|
||||||
t.Run("proxy request uses proxy authorization username", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.Host = "target.example.com"
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
|
||||||
req.SetBasicAuth("direct-user", "direct-pass")
|
|
||||||
|
|
||||||
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("connect request keeps proxy authorization routing", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("CONNECT", "http://target.example.com:443", nil)
|
|
||||||
req.Host = "target.example.com:443"
|
|
||||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
|
||||||
req.SetBasicAuth("direct-user", "direct-pass")
|
|
||||||
|
|
||||||
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("direct request uses authorization username", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Host = "example.com"
|
|
||||||
req.SetBasicAuth("direct-user", "direct-pass")
|
|
||||||
|
|
||||||
require.Equal(t, "direct-user", getRequestRouteUser(req))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("proxy request does not fall back when proxy authorization is invalid", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
||||||
req.Host = "target.example.com"
|
|
||||||
req.Header.Set("Proxy-Authorization", "Basic !!!")
|
|
||||||
req.SetBasicAuth("direct-user", "direct-pass")
|
|
||||||
|
|
||||||
require.Empty(t, getRequestRouteUser(req))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"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/framework/consts"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/process"
|
"github.com/fatedier/frp/test/e2e/pkg/process"
|
||||||
@@ -62,22 +61,9 @@ func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string
|
|||||||
err = p.Start()
|
err = p.Start()
|
||||||
ExpectNoError(err)
|
ExpectNoError(err)
|
||||||
}
|
}
|
||||||
// Wait for each client's proxies to register with frps.
|
// frpc needs time to connect and register proxies with frps.
|
||||||
// If any client has no proxies (e.g. visitor-only), fall back to sleep
|
if len(clientProcesses) > 0 {
|
||||||
// for the remaining time since visitors have no deterministic readiness signal.
|
time.Sleep(1500 * time.Millisecond)
|
||||||
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 serverProcess, clientProcesses
|
||||||
@@ -119,55 +105,6 @@ func (f *Framework) GenerateConfigFile(content string) string {
|
|||||||
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.
|
// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
|
||||||
func WaitForTCPReady(addr string, timeout time.Duration) error {
|
func WaitForTCPReady(addr string, timeout time.Duration) error {
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
|
|||||||
@@ -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,8 +55,8 @@ 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(serverConf, []string{clientConf})
|
||||||
|
|
||||||
@@ -67,7 +65,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
|||||||
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -4,37 +4,15 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
|
||||||
"sync"
|
"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{}
|
done chan struct{}
|
||||||
closeOne sync.Once
|
closeOne sync.Once
|
||||||
@@ -58,8 +36,8 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
done: make(chan struct{}),
|
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
|
||||||
@@ -120,34 +98,6 @@ func (p *Process) Output() string {
|
|||||||
return p.stdOutput.String() + p.errorOutput.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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -144,79 +144,6 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
|||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("HTTP proxy mode uses proxy auth consistently", func() {
|
|
||||||
vhostHTTPPort := f.AllocPort()
|
|
||||||
serverConf := getDefaultServerConf(vhostHTTPPort)
|
|
||||||
|
|
||||||
backendPort := f.AllocPort()
|
|
||||||
f.RunServer("", newHTTPServer(backendPort, "PRIVATE"))
|
|
||||||
|
|
||||||
clientConf := consts.DefaultClientConfig
|
|
||||||
clientConf += fmt.Sprintf(`
|
|
||||||
[[proxies]]
|
|
||||||
name = "protected"
|
|
||||||
type = "http"
|
|
||||||
localPort = %d
|
|
||||||
customDomains = ["normal.example.com"]
|
|
||||||
routeByHTTPUser = "alice"
|
|
||||||
httpUser = "alice"
|
|
||||||
httpPassword = "secret"
|
|
||||||
`, backendPort)
|
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
|
||||||
|
|
||||||
proxyURLWithAuth := func(username, password string) string {
|
|
||||||
if username == "" {
|
|
||||||
return fmt.Sprintf("http://127.0.0.1:%d", vhostHTTPPort)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("http://%s:%s@127.0.0.1:%d", username, password, vhostHTTPPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("direct no auth").Port(vhostHTTPPort).
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().HTTPHost("normal.example.com")
|
|
||||||
}).
|
|
||||||
Ensure(framework.ExpectResponseCode(http.StatusNotFound))
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("direct correct auth").Port(vhostHTTPPort).
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().HTTPHost("normal.example.com").HTTPAuth("alice", "secret")
|
|
||||||
}).
|
|
||||||
ExpectResp([]byte("PRIVATE")).
|
|
||||||
Ensure()
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("direct wrong auth").Port(vhostHTTPPort).
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().HTTPHost("normal.example.com").HTTPAuth("alice", "wrong")
|
|
||||||
}).
|
|
||||||
Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("proxy correct proxy auth").
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "secret"))
|
|
||||||
}).
|
|
||||||
ExpectResp([]byte("PRIVATE")).
|
|
||||||
Ensure()
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("proxy wrong proxy auth").
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "wrong"))
|
|
||||||
}).
|
|
||||||
Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("proxy request ignores authorization header").
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("", "")).HTTPAuth("alice", "secret")
|
|
||||||
}).
|
|
||||||
Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).Explain("proxy wrong proxy auth with correct authorization").
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "wrong")).HTTPAuth("alice", "secret")
|
|
||||||
}).
|
|
||||||
Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
|
|
||||||
})
|
|
||||||
|
|
||||||
ginkgo.It("HTTP Basic Auth", func() {
|
ginkgo.It("HTTP Basic Auth", func() {
|
||||||
vhostHTTPPort := f.AllocPort()
|
vhostHTTPPort := f.AllocPort()
|
||||||
serverConf := getDefaultServerConf(vhostHTTPPort)
|
serverConf := getDefaultServerConf(vhostHTTPPort)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,8 +64,8 @@ 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(serverConf, []string{clientConf})
|
||||||
|
|
||||||
@@ -76,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
|||||||
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(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(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"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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()
|
||||||
})
|
})
|
||||||
@@ -72,7 +72,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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()
|
||||||
})
|
})
|
||||||
@@ -127,7 +126,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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 {
|
||||||
@@ -225,7 +226,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort)
|
`, adminPort)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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")
|
||||||
@@ -247,7 +248,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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",
|
||||||
@@ -280,7 +281,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
33
web/frpc/components.d.ts
vendored
33
web/frpc/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
1163
web/package-lock.json → web/frpc/package-lock.json
generated
1163
web/package-lock.json → web/frpc/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,16 +2,17 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
<div class="header-top">
|
||||||
<div class="brand-section">
|
<div class="brand-section">
|
||||||
<button v-if="isMobile" class="hamburger-btn" @click="toggleSidebar" aria-label="Toggle menu">
|
|
||||||
<span class="hamburger-icon">☰</span>
|
|
||||||
</button>
|
|
||||||
<div class="logo-wrapper">
|
<div class="logo-wrapper">
|
||||||
<LogoIcon class="logo-icon" />
|
<LogoIcon class="logo-icon" />
|
||||||
</div>
|
</div>
|
||||||
<span class="divider">/</span>
|
<span class="divider">/</span>
|
||||||
<span class="brand-name">frp</span>
|
<span class="brand-name">frp</span>
|
||||||
<span class="badge">Client</span>
|
<span class="badge client-badge">Client</span>
|
||||||
|
<span class="badge" v-if="currentRouteName">{{
|
||||||
|
currentRouteName
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
@@ -32,130 +33,109 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="layout">
|
<nav class="nav-bar">
|
||||||
<!-- Mobile overlay -->
|
<router-link to="/" class="nav-link" active-class="active"
|
||||||
<div
|
>Overview</router-link
|
||||||
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 to="/configure" class="nav-link" active-class="active"
|
||||||
</router-link>
|
>Configure</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>
|
</nav>
|
||||||
</aside>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main id="content">
|
<main id="content">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</main>
|
</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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
105
web/frpc/src/assets/css/custom.css
Normal file
105
web/frpc/src/assets/css/custom.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="kv-editor">
|
<div class="kv-editor">
|
||||||
<template v-if="readonly">
|
|
||||||
<div v-if="modelValue.length === 0" class="kv-empty">—</div>
|
|
||||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row">
|
|
||||||
<span class="kv-readonly-key">{{ entry.key }}</span>
|
|
||||||
<span class="kv-readonly-value">{{ entry.value }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="entry.key"
|
:model-value="entry.key"
|
||||||
@@ -45,7 +37,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</template>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
202
web/frpc/src/components/StatCard.vue
Normal file
202
web/frpc/src/components/StatCard.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="HTTP Options" collapsible :readonly="readonly"
|
|
||||||
:has-value="form.locations.length > 0 || !!form.hostHeaderRewrite || form.requestHeaders.length > 0 || form.responseHeaders.length > 0">
|
|
||||||
<ConfigField label="Locations" type="tags" v-model="form.locations" placeholder="/path" :readonly="readonly" />
|
|
||||||
<ConfigField label="Host Header Rewrite" type="text" v-model="form.hostHeaderRewrite" :readonly="readonly" />
|
|
||||||
<ConfigField label="Request Headers" type="kv" v-model="form.requestHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
|
|
||||||
<ConfigField label="Response Headers" type="kv" v-model="form.responseHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
|
|
||||||
</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>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="Load Balancer" collapsible :readonly="readonly" :has-value="!!form.loadBalancerGroup">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Group" type="text" v-model="form.loadBalancerGroup" placeholder="Group name" :readonly="readonly" />
|
|
||||||
<ConfigField label="Group Key" type="text" v-model="form.loadBalancerGroupKey" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="Metadata" collapsible :readonly="readonly" :has-value="form.metadatas.length > 0 || form.annotations.length > 0">
|
|
||||||
<ConfigField label="Metadatas" type="kv" v-model="form.metadatas" :readonly="readonly" />
|
|
||||||
<ConfigField label="Annotations" type="kv" v-model="form.annotations" :readonly="readonly" />
|
|
||||||
</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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly" :has-value="form.natTraversalDisableAssistedAddrs">
|
|
||||||
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
|
|
||||||
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
|
|
||||||
</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>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<template v-if="['tcp', 'udp'].includes(form.type)">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Remote Port" type="number" v-model="form.remotePort"
|
|
||||||
:min="0" :max="65535" prop="remotePort" tip="Use 0 for random port assignment" :readonly="readonly" />
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-if="['http', 'https', 'tcpmux'].includes(form.type)">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Custom Domains" type="tags" v-model="form.customDomains"
|
|
||||||
prop="customDomains" placeholder="example.com" :readonly="readonly" />
|
|
||||||
<ConfigField v-if="form.type !== 'tcpmux'" label="Subdomain" type="text"
|
|
||||||
v-model="form.subdomain" placeholder="test" :readonly="readonly" />
|
|
||||||
<ConfigField v-if="form.type === 'tcpmux'" label="Multiplexer" type="select"
|
|
||||||
v-model="form.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { ProxyFormData } from '../../types'
|
|
||||||
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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="Transport" collapsible :readonly="readonly"
|
|
||||||
:has-value="form.useEncryption || form.useCompression || !!form.bandwidthLimit || (!!form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || !!form.proxyProtocolVersion">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
|
|
||||||
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
<div class="field-row three-col">
|
|
||||||
<ConfigField label="Bandwidth Limit" type="text" v-model="form.bandwidthLimit" placeholder="1MB" tip="e.g., 1MB, 500KB" :readonly="readonly" />
|
|
||||||
<ConfigField label="Bandwidth Limit Mode" type="select" v-model="form.bandwidthLimitMode"
|
|
||||||
:options="[{ label: 'Client', value: 'client' }, { label: 'Server', value: 'server' }]" :readonly="readonly" />
|
|
||||||
<ConfigField label="Proxy Protocol Version" type="select" v-model="form.proxyProtocolVersion"
|
|
||||||
:options="[{ label: 'None', value: '' }, { label: 'v1', value: 'v1' }, { label: 'v2', value: 'v2' }]" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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-visitor" />
|
|
||||||
</el-form-item>
|
|
||||||
<ConfigField label="Type" type="select" v-model="form.type" :disabled="editing"
|
|
||||||
:options="[{ label: 'STCP', value: 'stcp' }, { label: 'SUDP', value: 'sudp' }, { label: 'XTCP', value: 'xtcp' }]" 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 type { VisitorFormData } from '../../types'
|
|
||||||
import ConfigField from '../ConfigField.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: VisitorFormData
|
|
||||||
readonly?: boolean
|
|
||||||
editing?: boolean
|
|
||||||
}>(), { readonly: false, editing: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
|
||||||
|
|
||||||
const form = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use '@/assets/css/form-layout';
|
|
||||||
</style>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="Connection" :readonly="readonly">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Server Name" type="text" v-model="form.serverName" prop="serverName"
|
|
||||||
placeholder="Name of the proxy to visit" :readonly="readonly" />
|
|
||||||
<ConfigField label="Server User" type="text" v-model="form.serverUser"
|
|
||||||
placeholder="Leave empty for same user" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
<ConfigField label="Secret Key" type="password" v-model="form.secretKey"
|
|
||||||
placeholder="Shared secret" :readonly="readonly" />
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
|
|
||||||
placeholder="127.0.0.1" :readonly="readonly" />
|
|
||||||
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
|
|
||||||
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { VisitorFormData } from '../../types'
|
|
||||||
import ConfigSection from '../ConfigSection.vue'
|
|
||||||
import ConfigField from '../ConfigField.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: VisitorFormData
|
|
||||||
readonly?: boolean
|
|
||||||
}>(), { readonly: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
|
||||||
|
|
||||||
const form = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
})
|
|
||||||
|
|
||||||
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use '@/assets/css/form-layout';
|
|
||||||
</style>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="visitor-form-layout">
|
|
||||||
<ConfigSection :readonly="readonly">
|
|
||||||
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
|
|
||||||
</ConfigSection>
|
|
||||||
<VisitorConnectionSection v-model="form" :readonly="readonly" />
|
|
||||||
<VisitorTransportSection v-model="form" :readonly="readonly" />
|
|
||||||
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { VisitorFormData } from '../../types'
|
|
||||||
import ConfigSection from '../ConfigSection.vue'
|
|
||||||
import VisitorBaseSection from './VisitorBaseSection.vue'
|
|
||||||
import VisitorConnectionSection from './VisitorConnectionSection.vue'
|
|
||||||
import VisitorTransportSection from './VisitorTransportSection.vue'
|
|
||||||
import VisitorXtcpSection from './VisitorXtcpSection.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: VisitorFormData
|
|
||||||
readonly?: boolean
|
|
||||||
editing?: boolean
|
|
||||||
}>(), { readonly: false, editing: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
|
||||||
|
|
||||||
const form = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ConfigSection title="Transport Options" collapsible :readonly="readonly"
|
|
||||||
:has-value="form.useEncryption || form.useCompression">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
|
|
||||||
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { VisitorFormData } from '../../types'
|
|
||||||
import ConfigSection from '../ConfigSection.vue'
|
|
||||||
import ConfigField from '../ConfigField.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: VisitorFormData
|
|
||||||
readonly?: boolean
|
|
||||||
}>(), { readonly: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
|
||||||
|
|
||||||
const form = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use '@/assets/css/form-layout';
|
|
||||||
</style>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- XTCP Options -->
|
|
||||||
<ConfigSection title="XTCP Options" collapsible :readonly="readonly"
|
|
||||||
:has-value="form.protocol !== 'quic' || form.keepTunnelOpen || form.maxRetriesAnHour != null || form.minRetryInterval != null || !!form.fallbackTo || form.fallbackTimeoutMs != null">
|
|
||||||
<ConfigField label="Protocol" type="select" v-model="form.protocol"
|
|
||||||
:options="[{ label: 'QUIC', value: 'quic' }, { label: 'KCP', value: 'kcp' }]" :readonly="readonly" />
|
|
||||||
<ConfigField label="Keep Tunnel Open" type="switch" v-model="form.keepTunnelOpen" :readonly="readonly" />
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Max Retries per Hour" type="number" v-model="form.maxRetriesAnHour" :min="0" :readonly="readonly" />
|
|
||||||
<ConfigField label="Min Retry Interval (s)" type="number" v-model="form.minRetryInterval" :min="0" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<ConfigField label="Fallback To" type="text" v-model="form.fallbackTo" placeholder="Fallback visitor name" :readonly="readonly" />
|
|
||||||
<ConfigField label="Fallback Timeout (ms)" type="number" v-model="form.fallbackTimeoutMs" :min="0" :readonly="readonly" />
|
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
<!-- NAT Traversal -->
|
|
||||||
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly"
|
|
||||||
:has-value="form.natTraversalDisableAssistedAddrs">
|
|
||||||
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
|
|
||||||
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
|
|
||||||
</ConfigSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { VisitorFormData } from '../../types'
|
|
||||||
import ConfigSection from '../ConfigSection.vue'
|
|
||||||
import ConfigField from '../ConfigField.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: VisitorFormData
|
|
||||||
readonly?: boolean
|
|
||||||
}>(), { readonly: false })
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
|
|
||||||
|
|
||||||
const form = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use '@/assets/css/form-layout';
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { useBreakpoints } from '@vueuse/core'
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })
|
|
||||||
|
|
||||||
export function useResponsive() {
|
|
||||||
const isMobile = breakpoints.smaller('desktop') // < 768px
|
|
||||||
return { isMobile }
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
import './assets/css/var.css'
|
import './assets/css/custom.css'
|
||||||
import './assets/css/dark.css'
|
import './assets/css/dark.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import Overview from '../views/Overview.vue'
|
||||||
import ClientConfigure from '../views/ClientConfigure.vue'
|
import ClientConfigure from '../views/ClientConfigure.vue'
|
||||||
import ProxyDetail from '../views/ProxyDetail.vue'
|
|
||||||
import ProxyEdit from '../views/ProxyEdit.vue'
|
import ProxyEdit from '../views/ProxyEdit.vue'
|
||||||
import ProxyList from '../views/ProxyList.vue'
|
|
||||||
import VisitorDetail from '../views/VisitorDetail.vue'
|
|
||||||
import VisitorEdit from '../views/VisitorEdit.vue'
|
import VisitorEdit from '../views/VisitorEdit.vue'
|
||||||
import VisitorList from '../views/VisitorList.vue'
|
import { listStoreProxies } from '../api/frpc'
|
||||||
import { useProxyStore } from '../stores/proxy'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/proxies',
|
name: 'Overview',
|
||||||
|
component: Overview,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/proxies',
|
path: '/configure',
|
||||||
name: 'ProxyList',
|
name: 'ClientConfigure',
|
||||||
component: ProxyList,
|
component: ClientConfigure,
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/proxies/detail/:name',
|
|
||||||
name: 'ProxyDetail',
|
|
||||||
component: ProxyDetail,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/proxies/create',
|
path: '/proxies/create',
|
||||||
@@ -38,16 +31,6 @@ const router = createRouter({
|
|||||||
component: ProxyEdit,
|
component: ProxyEdit,
|
||||||
meta: { requiresStore: true },
|
meta: { requiresStore: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/visitors',
|
|
||||||
name: 'VisitorList',
|
|
||||||
component: VisitorList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/visitors/detail/:name',
|
|
||||||
name: 'VisitorDetail',
|
|
||||||
component: VisitorDetail,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/visitors/create',
|
path: '/visitors/create',
|
||||||
name: 'VisitorCreate',
|
name: 'VisitorCreate',
|
||||||
@@ -60,21 +43,27 @@ const router = createRouter({
|
|||||||
component: VisitorEdit,
|
component: VisitorEdit,
|
||||||
meta: { requiresStore: true },
|
meta: { requiresStore: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/config',
|
|
||||||
name: 'ClientConfigure',
|
|
||||||
component: ClientConfigure,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isStoreEnabled = async () => {
|
||||||
|
try {
|
||||||
|
await listStoreProxies()
|
||||||
|
return true
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.status === 404) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (!to.matched.some((record) => record.meta.requiresStore)) {
|
if (!to.matched.some((record) => record.meta.requiresStore)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyStore = useProxyStore()
|
const enabled = await isStoreEnabled()
|
||||||
const enabled = await proxyStore.checkStoreEnabled()
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -82,7 +71,7 @@ router.beforeEach(async (to) => {
|
|||||||
ElMessage.warning(
|
ElMessage.warning(
|
||||||
'Store is disabled. Enable Store in frpc config to create or edit store entries.',
|
'Store is disabled. Enable Store in frpc config to create or edit store entries.',
|
||||||
)
|
)
|
||||||
return { name: 'ProxyList' }
|
return { name: 'Overview' }
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
|
||||||
|
|
||||||
export const useClientStore = defineStore('client', () => {
|
|
||||||
const config = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
config.value = await getConfig()
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveConfig = async (text: string) => {
|
|
||||||
await putConfig(text)
|
|
||||||
config.value = text
|
|
||||||
}
|
|
||||||
|
|
||||||
const reload = async () => {
|
|
||||||
await reloadConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config, loading, fetchConfig, saveConfig, reload }
|
|
||||||
})
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import type { ProxyStatus, ProxyDefinition } from '../types'
|
|
||||||
import {
|
|
||||||
getStatus,
|
|
||||||
listStoreProxies,
|
|
||||||
getStoreProxy,
|
|
||||||
createStoreProxy,
|
|
||||||
updateStoreProxy,
|
|
||||||
deleteStoreProxy,
|
|
||||||
} from '../api/frpc'
|
|
||||||
|
|
||||||
export const useProxyStore = defineStore('proxy', () => {
|
|
||||||
const proxies = ref<ProxyStatus[]>([])
|
|
||||||
const storeProxies = ref<ProxyDefinition[]>([])
|
|
||||||
const storeEnabled = ref(false)
|
|
||||||
const storeChecked = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const storeLoading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
const json = await getStatus()
|
|
||||||
const list: ProxyStatus[] = []
|
|
||||||
for (const key in json) {
|
|
||||||
for (const ps of json[key]) {
|
|
||||||
list.push(ps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
proxies.value = list
|
|
||||||
} catch (err: any) {
|
|
||||||
error.value = err.message
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStoreProxies = async () => {
|
|
||||||
storeLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await listStoreProxies()
|
|
||||||
storeProxies.value = res.proxies || []
|
|
||||||
storeEnabled.value = true
|
|
||||||
storeChecked.value = true
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.status === 404) {
|
|
||||||
storeEnabled.value = false
|
|
||||||
}
|
|
||||||
storeChecked.value = true
|
|
||||||
} finally {
|
|
||||||
storeLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkStoreEnabled = async () => {
|
|
||||||
if (storeChecked.value) return storeEnabled.value
|
|
||||||
await fetchStoreProxies()
|
|
||||||
return storeEnabled.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const createProxy = async (data: ProxyDefinition) => {
|
|
||||||
await createStoreProxy(data)
|
|
||||||
await fetchStoreProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProxy = async (name: string, data: ProxyDefinition) => {
|
|
||||||
await updateStoreProxy(name, data)
|
|
||||||
await fetchStoreProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProxy = async (name: string) => {
|
|
||||||
await deleteStoreProxy(name)
|
|
||||||
await fetchStoreProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleProxy = async (name: string, enabled: boolean) => {
|
|
||||||
const def = await getStoreProxy(name)
|
|
||||||
const block = (def as any)[def.type]
|
|
||||||
if (block) {
|
|
||||||
block.enabled = enabled
|
|
||||||
}
|
|
||||||
await updateStoreProxy(name, def)
|
|
||||||
await fetchStatus()
|
|
||||||
await fetchStoreProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const storeProxyWithStatus = (def: ProxyDefinition): ProxyStatus => {
|
|
||||||
const block = (def as any)[def.type]
|
|
||||||
const enabled = block?.enabled !== false
|
|
||||||
|
|
||||||
const localIP = block?.localIP || '127.0.0.1'
|
|
||||||
const localPort = block?.localPort
|
|
||||||
const local_addr = localPort != null ? `${localIP}:${localPort}` : ''
|
|
||||||
const remotePort = block?.remotePort
|
|
||||||
const remote_addr = remotePort != null ? `:${remotePort}` : ''
|
|
||||||
const plugin = block?.plugin?.type || ''
|
|
||||||
|
|
||||||
const status = proxies.value.find((p) => p.name === def.name)
|
|
||||||
return {
|
|
||||||
name: def.name,
|
|
||||||
type: def.type,
|
|
||||||
status: !enabled ? 'disabled' : (status?.status || 'waiting'),
|
|
||||||
err: status?.err || '',
|
|
||||||
local_addr: status?.local_addr || local_addr,
|
|
||||||
remote_addr: status?.remote_addr || remote_addr,
|
|
||||||
plugin: status?.plugin || plugin,
|
|
||||||
source: 'store',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
proxies,
|
|
||||||
storeProxies,
|
|
||||||
storeEnabled,
|
|
||||||
storeChecked,
|
|
||||||
loading,
|
|
||||||
storeLoading,
|
|
||||||
error,
|
|
||||||
fetchStatus,
|
|
||||||
fetchStoreProxies,
|
|
||||||
checkStoreEnabled,
|
|
||||||
createProxy,
|
|
||||||
updateProxy,
|
|
||||||
deleteProxy,
|
|
||||||
toggleProxy,
|
|
||||||
storeProxyWithStatus,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import type { VisitorDefinition } from '../types'
|
|
||||||
import {
|
|
||||||
listStoreVisitors,
|
|
||||||
createStoreVisitor,
|
|
||||||
updateStoreVisitor,
|
|
||||||
deleteStoreVisitor,
|
|
||||||
} from '../api/frpc'
|
|
||||||
|
|
||||||
export const useVisitorStore = defineStore('visitor', () => {
|
|
||||||
const storeVisitors = ref<VisitorDefinition[]>([])
|
|
||||||
const storeEnabled = ref(false)
|
|
||||||
const storeChecked = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
const fetchStoreVisitors = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await listStoreVisitors()
|
|
||||||
storeVisitors.value = res.visitors || []
|
|
||||||
storeEnabled.value = true
|
|
||||||
storeChecked.value = true
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.status === 404) {
|
|
||||||
storeEnabled.value = false
|
|
||||||
}
|
|
||||||
storeChecked.value = true
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkStoreEnabled = async () => {
|
|
||||||
if (storeChecked.value) return storeEnabled.value
|
|
||||||
await fetchStoreVisitors()
|
|
||||||
return storeEnabled.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const createVisitor = async (data: VisitorDefinition) => {
|
|
||||||
await createStoreVisitor(data)
|
|
||||||
await fetchStoreVisitors()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateVisitor = async (name: string, data: VisitorDefinition) => {
|
|
||||||
await updateStoreVisitor(name, data)
|
|
||||||
await fetchStoreVisitors()
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteVisitor = async (name: string) => {
|
|
||||||
await deleteStoreVisitor(name)
|
|
||||||
await fetchStoreVisitors()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
storeVisitors,
|
|
||||||
storeEnabled,
|
|
||||||
storeChecked,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchStoreVisitors,
|
|
||||||
checkStoreEnabled,
|
|
||||||
createVisitor,
|
|
||||||
updateVisitor,
|
|
||||||
deleteVisitor,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export const PROXY_TYPES = [
|
|
||||||
'tcp',
|
|
||||||
'udp',
|
|
||||||
'http',
|
|
||||||
'https',
|
|
||||||
'tcpmux',
|
|
||||||
'stcp',
|
|
||||||
'sudp',
|
|
||||||
'xtcp',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type ProxyType = (typeof PROXY_TYPES)[number]
|
|
||||||
|
|
||||||
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
|
|
||||||
|
|
||||||
export type VisitorType = (typeof VISITOR_TYPES)[number]
|
|
||||||
|
|
||||||
export const PLUGIN_TYPES = [
|
|
||||||
'',
|
|
||||||
'http2https',
|
|
||||||
'http_proxy',
|
|
||||||
'https2http',
|
|
||||||
'https2https',
|
|
||||||
'http2http',
|
|
||||||
'socks5',
|
|
||||||
'static_file',
|
|
||||||
'unix_domain_socket',
|
|
||||||
'tls2raw',
|
|
||||||
'virtual_net',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type PluginType = (typeof PLUGIN_TYPES)[number]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './constants'
|
|
||||||
export * from './proxy-status'
|
|
||||||
export * from './proxy-store'
|
|
||||||
export * from './proxy-form'
|
|
||||||
export * from './proxy-converters'
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { ProxyType, VisitorType } from './constants'
|
|
||||||
|
|
||||||
export interface ProxyFormData {
|
|
||||||
// Base fields (ProxyBaseConfig)
|
|
||||||
name: string
|
|
||||||
type: ProxyType
|
|
||||||
enabled: boolean
|
|
||||||
|
|
||||||
// Backend (ProxyBackend)
|
|
||||||
localIP: string
|
|
||||||
localPort: number | undefined
|
|
||||||
pluginType: string
|
|
||||||
pluginConfig: Record<string, any>
|
|
||||||
|
|
||||||
// Transport (ProxyTransport)
|
|
||||||
useEncryption: boolean
|
|
||||||
useCompression: boolean
|
|
||||||
bandwidthLimit: string
|
|
||||||
bandwidthLimitMode: string
|
|
||||||
proxyProtocolVersion: string
|
|
||||||
|
|
||||||
// Load Balancer (LoadBalancerConfig)
|
|
||||||
loadBalancerGroup: string
|
|
||||||
loadBalancerGroupKey: string
|
|
||||||
|
|
||||||
// Health Check (HealthCheckConfig)
|
|
||||||
healthCheckType: string
|
|
||||||
healthCheckTimeoutSeconds: number | undefined
|
|
||||||
healthCheckMaxFailed: number | undefined
|
|
||||||
healthCheckIntervalSeconds: number | undefined
|
|
||||||
healthCheckPath: string
|
|
||||||
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
|
|
||||||
|
|
||||||
// Metadata & Annotations
|
|
||||||
metadatas: Array<{ key: string; value: string }>
|
|
||||||
annotations: Array<{ key: string; value: string }>
|
|
||||||
|
|
||||||
// TCP/UDP specific
|
|
||||||
remotePort: number | undefined
|
|
||||||
|
|
||||||
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
|
|
||||||
customDomains: string[]
|
|
||||||
subdomain: string
|
|
||||||
|
|
||||||
// HTTP specific (HTTPProxyConfig)
|
|
||||||
locations: string[]
|
|
||||||
httpUser: string
|
|
||||||
httpPassword: string
|
|
||||||
hostHeaderRewrite: string
|
|
||||||
requestHeaders: Array<{ key: string; value: string }>
|
|
||||||
responseHeaders: Array<{ key: string; value: string }>
|
|
||||||
routeByHTTPUser: string
|
|
||||||
|
|
||||||
// TCPMux specific
|
|
||||||
multiplexer: string
|
|
||||||
|
|
||||||
// STCP/SUDP/XTCP specific
|
|
||||||
secretKey: string
|
|
||||||
allowUsers: string[]
|
|
||||||
|
|
||||||
// XTCP specific (NatTraversalConfig)
|
|
||||||
natTraversalDisableAssistedAddrs: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VisitorFormData {
|
|
||||||
// Base fields (VisitorBaseConfig)
|
|
||||||
name: string
|
|
||||||
type: VisitorType
|
|
||||||
enabled: boolean
|
|
||||||
|
|
||||||
// Transport (VisitorTransport)
|
|
||||||
useEncryption: boolean
|
|
||||||
useCompression: boolean
|
|
||||||
|
|
||||||
// Connection
|
|
||||||
secretKey: string
|
|
||||||
serverUser: string
|
|
||||||
serverName: string
|
|
||||||
bindAddr: string
|
|
||||||
bindPort: number | undefined
|
|
||||||
|
|
||||||
// XTCP specific (XTCPVisitorConfig)
|
|
||||||
protocol: string
|
|
||||||
keepTunnelOpen: boolean
|
|
||||||
maxRetriesAnHour: number | undefined
|
|
||||||
minRetryInterval: number | undefined
|
|
||||||
fallbackTo: string
|
|
||||||
fallbackTimeoutMs: number | undefined
|
|
||||||
natTraversalDisableAssistedAddrs: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultProxyForm(): ProxyFormData {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
type: 'tcp',
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
localIP: '127.0.0.1',
|
|
||||||
localPort: undefined,
|
|
||||||
pluginType: '',
|
|
||||||
pluginConfig: {},
|
|
||||||
|
|
||||||
useEncryption: false,
|
|
||||||
useCompression: false,
|
|
||||||
bandwidthLimit: '',
|
|
||||||
bandwidthLimitMode: 'client',
|
|
||||||
proxyProtocolVersion: '',
|
|
||||||
|
|
||||||
loadBalancerGroup: '',
|
|
||||||
loadBalancerGroupKey: '',
|
|
||||||
|
|
||||||
healthCheckType: '',
|
|
||||||
healthCheckTimeoutSeconds: undefined,
|
|
||||||
healthCheckMaxFailed: undefined,
|
|
||||||
healthCheckIntervalSeconds: undefined,
|
|
||||||
healthCheckPath: '',
|
|
||||||
healthCheckHTTPHeaders: [],
|
|
||||||
|
|
||||||
metadatas: [],
|
|
||||||
annotations: [],
|
|
||||||
|
|
||||||
remotePort: undefined,
|
|
||||||
|
|
||||||
customDomains: [],
|
|
||||||
subdomain: '',
|
|
||||||
|
|
||||||
locations: [],
|
|
||||||
httpUser: '',
|
|
||||||
httpPassword: '',
|
|
||||||
hostHeaderRewrite: '',
|
|
||||||
requestHeaders: [],
|
|
||||||
responseHeaders: [],
|
|
||||||
routeByHTTPUser: '',
|
|
||||||
|
|
||||||
multiplexer: 'httpconnect',
|
|
||||||
|
|
||||||
secretKey: '',
|
|
||||||
allowUsers: [],
|
|
||||||
|
|
||||||
natTraversalDisableAssistedAddrs: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultVisitorForm(): VisitorFormData {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
type: 'stcp',
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
useEncryption: false,
|
|
||||||
useCompression: false,
|
|
||||||
|
|
||||||
secretKey: '',
|
|
||||||
serverUser: '',
|
|
||||||
serverName: '',
|
|
||||||
bindAddr: '127.0.0.1',
|
|
||||||
bindPort: undefined,
|
|
||||||
|
|
||||||
protocol: 'quic',
|
|
||||||
keepTunnelOpen: false,
|
|
||||||
maxRetriesAnHour: undefined,
|
|
||||||
minRetryInterval: undefined,
|
|
||||||
fallbackTo: '',
|
|
||||||
fallbackTimeoutMs: undefined,
|
|
||||||
natTraversalDisableAssistedAddrs: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface ProxyStatus {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
err: string
|
|
||||||
local_addr: string
|
|
||||||
plugin: string
|
|
||||||
remote_addr: string
|
|
||||||
source?: 'store' | 'config'
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StatusResponse = Record<string, ProxyStatus[]>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { ProxyType, VisitorType } from './constants'
|
|
||||||
|
|
||||||
export interface ProxyDefinition {
|
|
||||||
name: string
|
|
||||||
type: ProxyType
|
|
||||||
tcp?: Record<string, any>
|
|
||||||
udp?: Record<string, any>
|
|
||||||
http?: Record<string, any>
|
|
||||||
https?: Record<string, any>
|
|
||||||
tcpmux?: Record<string, any>
|
|
||||||
stcp?: Record<string, any>
|
|
||||||
sudp?: Record<string, any>
|
|
||||||
xtcp?: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VisitorDefinition {
|
|
||||||
name: string
|
|
||||||
type: VisitorType
|
|
||||||
stcp?: Record<string, any>
|
|
||||||
sudp?: Record<string, any>
|
|
||||||
xtcp?: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyListResp {
|
|
||||||
proxies: ProxyDefinition[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VisitorListResp {
|
|
||||||
visitors: VisitorDefinition[]
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,264 @@
|
|||||||
import type { ProxyType, VisitorType } from './constants'
|
// ========================================
|
||||||
import type { ProxyFormData, VisitorFormData } from './proxy-form'
|
// RUNTIME STATUS TYPES (from /api/status)
|
||||||
import { createDefaultProxyForm, createDefaultVisitorForm } from './proxy-form'
|
// ========================================
|
||||||
import type { ProxyDefinition, VisitorDefinition } from './proxy-store'
|
|
||||||
|
export interface ProxyStatus {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
err: string
|
||||||
|
local_addr: string
|
||||||
|
plugin: string
|
||||||
|
remote_addr: string
|
||||||
|
source?: 'store' | 'config'
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusResponse = Record<string, ProxyStatus[]>
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// STORE API TYPES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface ProxyDefinition {
|
||||||
|
name: string
|
||||||
|
type: ProxyType
|
||||||
|
tcp?: Record<string, any>
|
||||||
|
udp?: Record<string, any>
|
||||||
|
http?: Record<string, any>
|
||||||
|
https?: Record<string, any>
|
||||||
|
tcpmux?: Record<string, any>
|
||||||
|
stcp?: Record<string, any>
|
||||||
|
sudp?: Record<string, any>
|
||||||
|
xtcp?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitorDefinition {
|
||||||
|
name: string
|
||||||
|
type: VisitorType
|
||||||
|
stcp?: Record<string, any>
|
||||||
|
sudp?: Record<string, any>
|
||||||
|
xtcp?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyListResp {
|
||||||
|
proxies: ProxyDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitorListResp {
|
||||||
|
visitors: VisitorDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export const PROXY_TYPES = [
|
||||||
|
'tcp',
|
||||||
|
'udp',
|
||||||
|
'http',
|
||||||
|
'https',
|
||||||
|
'stcp',
|
||||||
|
'sudp',
|
||||||
|
'xtcp',
|
||||||
|
'tcpmux',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ProxyType = (typeof PROXY_TYPES)[number]
|
||||||
|
|
||||||
|
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
|
||||||
|
|
||||||
|
export type VisitorType = (typeof VISITOR_TYPES)[number]
|
||||||
|
|
||||||
|
export const PLUGIN_TYPES = [
|
||||||
|
'',
|
||||||
|
'http2https',
|
||||||
|
'http_proxy',
|
||||||
|
'https2http',
|
||||||
|
'https2https',
|
||||||
|
'http2http',
|
||||||
|
'socks5',
|
||||||
|
'static_file',
|
||||||
|
'unix_domain_socket',
|
||||||
|
'tls2raw',
|
||||||
|
'virtual_net',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type PluginType = (typeof PLUGIN_TYPES)[number]
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// FORM DATA INTERFACES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface ProxyFormData {
|
||||||
|
// Base fields (ProxyBaseConfig)
|
||||||
|
name: string
|
||||||
|
type: ProxyType
|
||||||
|
enabled: boolean
|
||||||
|
|
||||||
|
// Backend (ProxyBackend)
|
||||||
|
localIP: string
|
||||||
|
localPort: number | undefined
|
||||||
|
pluginType: string
|
||||||
|
pluginConfig: Record<string, any>
|
||||||
|
|
||||||
|
// Transport (ProxyTransport)
|
||||||
|
useEncryption: boolean
|
||||||
|
useCompression: boolean
|
||||||
|
bandwidthLimit: string
|
||||||
|
bandwidthLimitMode: string
|
||||||
|
proxyProtocolVersion: string
|
||||||
|
|
||||||
|
// Load Balancer (LoadBalancerConfig)
|
||||||
|
loadBalancerGroup: string
|
||||||
|
loadBalancerGroupKey: string
|
||||||
|
|
||||||
|
// Health Check (HealthCheckConfig)
|
||||||
|
healthCheckType: string
|
||||||
|
healthCheckTimeoutSeconds: number | undefined
|
||||||
|
healthCheckMaxFailed: number | undefined
|
||||||
|
healthCheckIntervalSeconds: number | undefined
|
||||||
|
healthCheckPath: string
|
||||||
|
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
|
||||||
|
|
||||||
|
// Metadata & Annotations
|
||||||
|
metadatas: Array<{ key: string; value: string }>
|
||||||
|
annotations: Array<{ key: string; value: string }>
|
||||||
|
|
||||||
|
// TCP/UDP specific
|
||||||
|
remotePort: number | undefined
|
||||||
|
|
||||||
|
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
|
||||||
|
customDomains: string
|
||||||
|
subdomain: string
|
||||||
|
|
||||||
|
// HTTP specific (HTTPProxyConfig)
|
||||||
|
locations: string
|
||||||
|
httpUser: string
|
||||||
|
httpPassword: string
|
||||||
|
hostHeaderRewrite: string
|
||||||
|
requestHeaders: Array<{ key: string; value: string }>
|
||||||
|
responseHeaders: Array<{ key: string; value: string }>
|
||||||
|
routeByHTTPUser: string
|
||||||
|
|
||||||
|
// TCPMux specific
|
||||||
|
multiplexer: string
|
||||||
|
|
||||||
|
// STCP/SUDP/XTCP specific
|
||||||
|
secretKey: string
|
||||||
|
allowUsers: string
|
||||||
|
|
||||||
|
// XTCP specific (NatTraversalConfig)
|
||||||
|
natTraversalDisableAssistedAddrs: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitorFormData {
|
||||||
|
// Base fields (VisitorBaseConfig)
|
||||||
|
name: string
|
||||||
|
type: VisitorType
|
||||||
|
enabled: boolean
|
||||||
|
|
||||||
|
// Transport (VisitorTransport)
|
||||||
|
useEncryption: boolean
|
||||||
|
useCompression: boolean
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
secretKey: string
|
||||||
|
serverUser: string
|
||||||
|
serverName: string
|
||||||
|
bindAddr: string
|
||||||
|
bindPort: number | undefined
|
||||||
|
|
||||||
|
// XTCP specific (XTCPVisitorConfig)
|
||||||
|
protocol: string
|
||||||
|
keepTunnelOpen: boolean
|
||||||
|
maxRetriesAnHour: number | undefined
|
||||||
|
minRetryInterval: number | undefined
|
||||||
|
fallbackTo: string
|
||||||
|
fallbackTimeoutMs: number | undefined
|
||||||
|
natTraversalDisableAssistedAddrs: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DEFAULT FORM CREATORS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function createDefaultProxyForm(): ProxyFormData {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
type: 'tcp',
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
|
localIP: '127.0.0.1',
|
||||||
|
localPort: undefined,
|
||||||
|
pluginType: '',
|
||||||
|
pluginConfig: {},
|
||||||
|
|
||||||
|
useEncryption: false,
|
||||||
|
useCompression: false,
|
||||||
|
bandwidthLimit: '',
|
||||||
|
bandwidthLimitMode: 'client',
|
||||||
|
proxyProtocolVersion: '',
|
||||||
|
|
||||||
|
loadBalancerGroup: '',
|
||||||
|
loadBalancerGroupKey: '',
|
||||||
|
|
||||||
|
healthCheckType: '',
|
||||||
|
healthCheckTimeoutSeconds: undefined,
|
||||||
|
healthCheckMaxFailed: undefined,
|
||||||
|
healthCheckIntervalSeconds: undefined,
|
||||||
|
healthCheckPath: '',
|
||||||
|
healthCheckHTTPHeaders: [],
|
||||||
|
|
||||||
|
metadatas: [],
|
||||||
|
annotations: [],
|
||||||
|
|
||||||
|
remotePort: undefined,
|
||||||
|
|
||||||
|
customDomains: '',
|
||||||
|
subdomain: '',
|
||||||
|
|
||||||
|
locations: '',
|
||||||
|
httpUser: '',
|
||||||
|
httpPassword: '',
|
||||||
|
hostHeaderRewrite: '',
|
||||||
|
requestHeaders: [],
|
||||||
|
responseHeaders: [],
|
||||||
|
routeByHTTPUser: '',
|
||||||
|
|
||||||
|
multiplexer: 'httpconnect',
|
||||||
|
|
||||||
|
secretKey: '',
|
||||||
|
allowUsers: '',
|
||||||
|
|
||||||
|
natTraversalDisableAssistedAddrs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultVisitorForm(): VisitorFormData {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
type: 'stcp',
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
|
useEncryption: false,
|
||||||
|
useCompression: false,
|
||||||
|
|
||||||
|
secretKey: '',
|
||||||
|
serverUser: '',
|
||||||
|
serverName: '',
|
||||||
|
bindAddr: '127.0.0.1',
|
||||||
|
bindPort: undefined,
|
||||||
|
|
||||||
|
protocol: 'quic',
|
||||||
|
keepTunnelOpen: false,
|
||||||
|
maxRetriesAnHour: undefined,
|
||||||
|
minRetryInterval: undefined,
|
||||||
|
fallbackTo: '',
|
||||||
|
fallbackTimeoutMs: undefined,
|
||||||
|
natTraversalDisableAssistedAddrs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// CONVERTERS: Form -> Store API
|
// CONVERTERS: Form -> Store API
|
||||||
@@ -102,8 +359,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
|
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
|
||||||
if (form.customDomains.length > 0) {
|
if (form.customDomains) {
|
||||||
block.customDomains = form.customDomains.filter(Boolean)
|
block.customDomains = form.customDomains
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
if (form.subdomain) {
|
if (form.subdomain) {
|
||||||
block.subdomain = form.subdomain
|
block.subdomain = form.subdomain
|
||||||
@@ -111,8 +371,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.type === 'http') {
|
if (form.type === 'http') {
|
||||||
if (form.locations.length > 0) {
|
if (form.locations) {
|
||||||
block.locations = form.locations.filter(Boolean)
|
block.locations = form.locations
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
if (form.httpUser) block.httpUser = form.httpUser
|
if (form.httpUser) block.httpUser = form.httpUser
|
||||||
if (form.httpPassword) block.httpPassword = form.httpPassword
|
if (form.httpPassword) block.httpPassword = form.httpPassword
|
||||||
@@ -146,8 +409,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
|
|||||||
|
|
||||||
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
|
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
|
||||||
if (form.secretKey) block.secretKey = form.secretKey
|
if (form.secretKey) block.secretKey = form.secretKey
|
||||||
if (form.allowUsers.length > 0) {
|
if (form.allowUsers) {
|
||||||
block.allowUsers = form.allowUsers.filter(Boolean)
|
block.allowUsers = form.allowUsers
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,17 +644,17 @@ export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
|
|||||||
|
|
||||||
// Domain config
|
// Domain config
|
||||||
if (Array.isArray(c.customDomains)) {
|
if (Array.isArray(c.customDomains)) {
|
||||||
form.customDomains = c.customDomains
|
form.customDomains = c.customDomains.join(', ')
|
||||||
} else if (c.customDomains) {
|
} else if (c.customDomains) {
|
||||||
form.customDomains = [c.customDomains]
|
form.customDomains = c.customDomains
|
||||||
}
|
}
|
||||||
form.subdomain = c.subdomain || ''
|
form.subdomain = c.subdomain || ''
|
||||||
|
|
||||||
// HTTP specific
|
// HTTP specific
|
||||||
if (Array.isArray(c.locations)) {
|
if (Array.isArray(c.locations)) {
|
||||||
form.locations = c.locations
|
form.locations = c.locations.join(', ')
|
||||||
} else if (c.locations) {
|
} else if (c.locations) {
|
||||||
form.locations = [c.locations]
|
form.locations = c.locations
|
||||||
}
|
}
|
||||||
form.httpUser = c.httpUser || ''
|
form.httpUser = c.httpUser || ''
|
||||||
form.httpPassword = c.httpPassword || ''
|
form.httpPassword = c.httpPassword || ''
|
||||||
@@ -413,9 +679,9 @@ export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
|
|||||||
// Secure types
|
// Secure types
|
||||||
form.secretKey = c.secretKey || ''
|
form.secretKey = c.secretKey || ''
|
||||||
if (Array.isArray(c.allowUsers)) {
|
if (Array.isArray(c.allowUsers)) {
|
||||||
form.allowUsers = c.allowUsers
|
form.allowUsers = c.allowUsers.join(', ')
|
||||||
} else if (c.allowUsers) {
|
} else if (c.allowUsers) {
|
||||||
form.allowUsers = [c.allowUsers]
|
form.allowUsers = c.allowUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
// XTCP NAT traversal
|
// XTCP NAT traversal
|
||||||
@@ -2,68 +2,127 @@
|
|||||||
<div class="configure-page">
|
<div class="configure-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="title-section">
|
<div class="title-section">
|
||||||
<h1 class="page-title">Config</h1>
|
<h1 class="page-title">Configuration</h1>
|
||||||
|
<p class="page-subtitle">
|
||||||
|
Edit and manage your frpc configuration file
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-header">
|
<el-row :gutter="20">
|
||||||
|
<el-col :xs="24" :lg="16">
|
||||||
|
<el-card class="editor-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
|
<span class="card-title">Configuration Editor</span>
|
||||||
|
<el-tag size="small" type="success">TOML</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-tooltip content="Refresh" placement="top">
|
||||||
|
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button type="primary" :icon="Upload" @click="handleUpload">
|
||||||
|
Update & Reload
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 20, maxRows: 40 }"
|
||||||
|
v-model="configContent"
|
||||||
|
placeholder="# frpc configuration file content...
|
||||||
|
|
||||||
|
[common]
|
||||||
|
server_addr = 127.0.0.1
|
||||||
|
server_port = 7000"
|
||||||
|
class="code-editor"
|
||||||
|
></el-input>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :xs="24" :lg="8">
|
||||||
|
<el-card class="help-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Quick Reference</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="help-content">
|
||||||
|
<div class="help-section">
|
||||||
|
<h4 class="help-section-title">Common Settings</h4>
|
||||||
|
<div class="help-items">
|
||||||
|
<div class="help-item">
|
||||||
|
<code>serverAddr</code>
|
||||||
|
<span>Server address</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-item">
|
||||||
|
<code>serverPort</code>
|
||||||
|
<span>Server port (default: 7000)</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-item">
|
||||||
|
<code>auth.token</code>
|
||||||
|
<span>Authentication token</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h4 class="help-section-title">Proxy Types</h4>
|
||||||
|
<div class="proxy-type-tags">
|
||||||
|
<el-tag type="primary" effect="plain">TCP</el-tag>
|
||||||
|
<el-tag type="success" effect="plain">UDP</el-tag>
|
||||||
|
<el-tag type="warning" effect="plain">HTTP</el-tag>
|
||||||
|
<el-tag type="danger" effect="plain">HTTPS</el-tag>
|
||||||
|
<el-tag type="info" effect="plain">STCP</el-tag>
|
||||||
|
<el-tag effect="plain">XTCP</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h4 class="help-section-title">Example Proxy</h4>
|
||||||
|
<pre class="code-example">
|
||||||
|
[[proxies]]
|
||||||
|
name = "web"
|
||||||
|
type = "http"
|
||||||
|
localPort = 80
|
||||||
|
customDomains = ["example.com"]</pre
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/fatedier/frp#configuration-files"
|
href="https://github.com/fatedier/frp#configuration-files"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="docs-link"
|
class="docs-link"
|
||||||
>
|
>
|
||||||
<el-icon><Link /></el-icon>
|
<el-icon><Link /></el-icon>
|
||||||
Documentation
|
View Full Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
|
||||||
<ActionButton @click="handleUpload">Update & Reload</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</el-card>
|
||||||
|
</el-col>
|
||||||
<div class="editor-wrapper">
|
</el-row>
|
||||||
<el-input
|
|
||||||
type="textarea"
|
|
||||||
:autosize="false"
|
|
||||||
v-model="configContent"
|
|
||||||
placeholder="# frpc configuration file content...
|
|
||||||
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = 7000"
|
|
||||||
class="code-editor"
|
|
||||||
></el-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model="confirmVisible"
|
|
||||||
title="Confirm Update"
|
|
||||||
message="This operation will update your frpc configuration and reload it. Do you want to continue?"
|
|
||||||
confirm-text="Update"
|
|
||||||
:loading="uploading"
|
|
||||||
:is-mobile="isMobile"
|
|
||||||
@confirm="doUpload"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Link } from '@element-plus/icons-vue'
|
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
|
||||||
import { useClientStore } from '../stores/client'
|
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
|
||||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
|
||||||
import { useResponsive } from '../composables/useResponsive'
|
|
||||||
|
|
||||||
const { isMobile } = useResponsive()
|
|
||||||
const clientStore = useClientStore()
|
|
||||||
const configContent = ref('')
|
const configContent = ref('')
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
await clientStore.fetchConfig()
|
const text = await getConfig()
|
||||||
configContent.value = clientStore.config
|
configContent.value = text
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
@@ -73,116 +132,256 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmVisible = ref(false)
|
|
||||||
const uploading = ref(false)
|
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = () => {
|
||||||
confirmVisible.value = true
|
ElMessageBox.confirm(
|
||||||
}
|
'This operation will update your frpc configuration and reload it. Do you want to continue?',
|
||||||
|
'Confirm Update',
|
||||||
const doUpload = async () => {
|
{
|
||||||
|
confirmButtonText: 'Update',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
if (!configContent.value.trim()) {
|
if (!configContent.value.trim()) {
|
||||||
ElMessage.warning('Configuration content cannot be empty!')
|
ElMessage({
|
||||||
|
message: 'Configuration content cannot be empty!',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
try {
|
try {
|
||||||
await clientStore.saveConfig(configContent.value)
|
await putConfig(configContent.value)
|
||||||
await clientStore.reload()
|
await reloadConfig()
|
||||||
ElMessage.success('Configuration updated and reloaded successfully')
|
ElMessage({
|
||||||
confirmVisible.value = false
|
type: 'success',
|
||||||
|
message: 'Configuration updated and reloaded successfully',
|
||||||
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error('Update failed: ' + err.message)
|
ElMessage({
|
||||||
} finally {
|
showClose: true,
|
||||||
uploading.value = false
|
message: 'Update failed: ' + err.message,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// cancelled
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped>
|
||||||
.configure-page {
|
.configure-page {
|
||||||
height: 100%;
|
display: flex;
|
||||||
overflow: hidden;
|
flex-direction: column;
|
||||||
padding: $spacing-xl 40px;
|
gap: 24px;
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
@include flex-column;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@include flex-column;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
flex-direction: column;
|
||||||
margin-bottom: $spacing-sm;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-header {
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-card,
|
||||||
|
.help-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .editor-card,
|
||||||
|
html.dark .help-card {
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
background: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-sm;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card-title {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor :deep(.el-textarea__inner) {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .code-editor :deep(.el-textarea__inner) {
|
||||||
|
background: #1e1e2d;
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor :deep(.el-textarea__inner:focus) {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help Card */
|
||||||
|
.help-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .help-item {
|
||||||
|
background: #1e1e2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item code {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item span {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-type-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-example {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .code-example {
|
||||||
|
background: #1e1e2d;
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-link {
|
.docs-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-xs;
|
gap: 8px;
|
||||||
color: $color-text-muted;
|
color: var(--el-color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: $font-size-sm;
|
font-size: 14px;
|
||||||
transition: color $transition-fast;
|
font-weight: 500;
|
||||||
|
padding: 12px 16px;
|
||||||
&:hover {
|
background: var(--el-color-primary-light-9);
|
||||||
color: $color-text-primary;
|
border-radius: 8px;
|
||||||
}
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor {
|
.docs-link:hover {
|
||||||
height: 100%;
|
background: var(--el-color-primary-light-8);
|
||||||
|
|
||||||
:deep(.el-textarea__inner) {
|
|
||||||
height: 100% !important;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
background: $color-bg-tertiary;
|
|
||||||
border: 1px solid $color-border-light;
|
|
||||||
resize: none;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: $color-text-light;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mobile {
|
@media (max-width: 768px) {
|
||||||
.configure-page {
|
.card-header {
|
||||||
padding: $spacing-xl $spacing-lg;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -193,4 +392,10 @@ fetchData()
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.help-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1160
web/frpc/src/views/Overview.vue
Normal file
1160
web/frpc/src/views/Overview.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,303 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="proxy-detail-page">
|
|
||||||
<!-- Fixed Header -->
|
|
||||||
<div class="detail-top">
|
|
||||||
<nav class="breadcrumb">
|
|
||||||
<router-link :to="isStore ? '/proxies?tab=store' : '/proxies'" class="breadcrumb-link">Proxies</router-link>
|
|
||||||
<span class="breadcrumb-sep">›</span>
|
|
||||||
<span class="breadcrumb-current">{{ proxyName }}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<template v-if="proxy">
|
|
||||||
<div class="detail-header">
|
|
||||||
<div>
|
|
||||||
<div class="header-title-row">
|
|
||||||
<h2 class="detail-title">{{ proxy.name }}</h2>
|
|
||||||
<span class="status-pill" :class="statusClass">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
{{ proxy.status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="header-subtitle">
|
|
||||||
Source: {{ displaySource }} · Type:
|
|
||||||
{{ proxy.type.toUpperCase() }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="isStore" class="header-actions">
|
|
||||||
<ActionButton variant="outline" size="small" @click="handleEdit">
|
|
||||||
Edit
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scrollable Content -->
|
|
||||||
<div v-if="notFound" class="not-found">
|
|
||||||
<p class="empty-text">Proxy not found</p>
|
|
||||||
<p class="empty-hint">The proxy "{{ proxyName }}" does not exist.</p>
|
|
||||||
<ActionButton variant="outline" @click="router.push('/proxies')">
|
|
||||||
Back to Proxies
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="proxy" v-loading="loading" class="detail-content">
|
|
||||||
<!-- Error Banner -->
|
|
||||||
<div v-if="proxy.err" class="error-banner">
|
|
||||||
<el-icon class="error-icon"><Warning /></el-icon>
|
|
||||||
<div>
|
|
||||||
<div class="error-title">Connection Error</div>
|
|
||||||
<div class="error-message">{{ proxy.err }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Sections -->
|
|
||||||
<ProxyFormLayout
|
|
||||||
v-if="formData"
|
|
||||||
:model-value="formData"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else v-loading="loading" class="loading-area"></div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { Warning } from '@element-plus/icons-vue'
|
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
|
||||||
import ProxyFormLayout from '../components/proxy-form/ProxyFormLayout.vue'
|
|
||||||
import { getProxyConfig, getStoreProxy } from '../api/frpc'
|
|
||||||
import { useProxyStore } from '../stores/proxy'
|
|
||||||
import { storeProxyToForm } from '../types'
|
|
||||||
import type { ProxyStatus, ProxyDefinition, ProxyFormData } from '../types'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const proxyStore = useProxyStore()
|
|
||||||
|
|
||||||
const proxyName = route.params.name as string
|
|
||||||
const proxy = ref<ProxyStatus | null>(null)
|
|
||||||
const proxyConfig = ref<ProxyDefinition | null>(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
const notFound = ref(false)
|
|
||||||
const isStore = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
// Try status API first
|
|
||||||
await proxyStore.fetchStatus()
|
|
||||||
const found = proxyStore.proxies.find((p) => p.name === proxyName)
|
|
||||||
|
|
||||||
// Try config API (works for any source)
|
|
||||||
let configDef: ProxyDefinition | null = null
|
|
||||||
try {
|
|
||||||
configDef = await getProxyConfig(proxyName)
|
|
||||||
proxyConfig.value = configDef
|
|
||||||
} catch {
|
|
||||||
// Config not available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if proxy is from the store (for Edit/Delete buttons)
|
|
||||||
try {
|
|
||||||
await getStoreProxy(proxyName)
|
|
||||||
isStore.value = true
|
|
||||||
} catch {
|
|
||||||
// Not a store proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
proxy.value = found
|
|
||||||
} else if (configDef) {
|
|
||||||
// Proxy not in status (e.g. disabled), build from config definition
|
|
||||||
const block = (configDef as any)[configDef.type]
|
|
||||||
const localIP = block?.localIP || '127.0.0.1'
|
|
||||||
const localPort = block?.localPort
|
|
||||||
const enabled = block?.enabled !== false
|
|
||||||
proxy.value = {
|
|
||||||
name: configDef.name,
|
|
||||||
type: configDef.type,
|
|
||||||
status: enabled ? 'waiting' : 'disabled',
|
|
||||||
err: '',
|
|
||||||
local_addr: localPort != null ? `${localIP}:${localPort}` : '',
|
|
||||||
remote_addr: block?.remotePort != null ? `:${block.remotePort}` : '',
|
|
||||||
plugin: block?.plugin?.type || '',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
notFound.value = true
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error('Failed to load proxy: ' + err.message)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const displaySource = computed(() =>
|
|
||||||
isStore.value ? 'store' : 'config',
|
|
||||||
)
|
|
||||||
|
|
||||||
const statusClass = computed(() => {
|
|
||||||
const s = proxy.value?.status
|
|
||||||
if (s === 'running') return 'running'
|
|
||||||
if (s === 'error') return 'error'
|
|
||||||
if (s === 'disabled') return 'disabled'
|
|
||||||
return 'waiting'
|
|
||||||
})
|
|
||||||
|
|
||||||
const formData = computed((): ProxyFormData | null => {
|
|
||||||
if (!proxyConfig.value) return null
|
|
||||||
return storeProxyToForm(proxyConfig.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
router.push('/proxies/' + encodeURIComponent(proxyName) + '/edit')
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.proxy-detail-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-top {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: $spacing-xl 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 24px 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
font-size: $font-size-md;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link {
|
|
||||||
color: $color-text-secondary;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $color-text-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-sep {
|
|
||||||
color: $color-text-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-current {
|
|
||||||
color: $color-text-primary;
|
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: $spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-md;
|
|
||||||
margin-bottom: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: $font-weight-semibold;
|
|
||||||
color: $color-text-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-subtitle {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $color-text-muted;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--color-danger-light);
|
|
||||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
margin-bottom: $spacing-xl;
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
color: $color-danger;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-title {
|
|
||||||
font-size: $font-size-md;
|
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
color: $color-danger;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $color-text-muted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-found,
|
|
||||||
.loading-area {
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px $spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: $font-size-lg;
|
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
color: $color-text-secondary;
|
|
||||||
margin: 0 0 $spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $color-text-muted;
|
|
||||||
margin: 0 0 $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
.detail-top {
|
|
||||||
padding: $spacing-xl $spacing-lg 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-content {
|
|
||||||
padding: 0 $spacing-lg $spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user