mirror of
https://github.com/fatedier/frp.git
synced 2026-04-09 10:39:16 +08:00
Compare commits
20 Commits
8d1ab7d585
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d667be7a0a | ||
|
|
31c3deb4f7 | ||
|
|
31e271939b | ||
|
|
061c141756 | ||
|
|
98ee1adb13 | ||
|
|
76abeff881 | ||
|
|
c694b1f6a9 | ||
|
|
5ed02275da | ||
|
|
60c4f5d4bd | ||
|
|
d20e384bf1 | ||
|
|
c95dc9d88a | ||
|
|
38a71a6803 | ||
|
|
6cdef90113 | ||
|
|
85e8e2c830 | ||
|
|
ff4ad2f907 | ||
|
|
94a631fe9c | ||
|
|
6b1be922e1 | ||
|
|
4f584f81d0 | ||
|
|
9669e1ca0c | ||
|
|
48e8901466 |
14
.github/workflows/build-and-push-image.yml
vendored
14
.github/workflows/build-and-push-image.yml
vendored
@@ -19,15 +19,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# environment
|
# environment
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
# get image tag name
|
# get image tag name
|
||||||
- name: Get Image Tag Name
|
- name: Get Image Tag Name
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to the GPR
|
- name: Login to the GPR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push frpc
|
- name: Build and push frpc
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frpc
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
${{ env.TAG_FRPC_GPR }}
|
${{ env.TAG_FRPC_GPR }}
|
||||||
|
|
||||||
- name: Build and push frps
|
- name: Build and push frps
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frps
|
file: ./dockerfiles/Dockerfile-for-frps
|
||||||
|
|||||||
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@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
cache: false
|
cache: false
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Build web assets (frps)
|
||||||
|
|||||||
8
.github/workflows/goreleaser.yml
vendored
8
.github/workflows/goreleaser.yml
vendored
@@ -8,15 +8,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Build web assets (frps)
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
./package.sh
|
./package.sh
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean --release-notes=./Release.md
|
args: release --clean --release-notes=./Release.md
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
actions: write
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.'
|
stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.'
|
||||||
stale-pr-message: "PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close."
|
stale-pr-message: "PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close."
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,10 +25,12 @@ dist/
|
|||||||
client.crt
|
client.crt
|
||||||
client.key
|
client.key
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
CLAUDE.md
|
.claude/
|
||||||
AGENTS.md
|
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gci
|
- gci
|
||||||
@@ -112,6 +113,7 @@ formatters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- `make build` - Build both frps and frpc binaries
|
||||||
|
- `make frps` - Build server binary only
|
||||||
|
- `make frpc` - Build client binary only
|
||||||
|
- `make all` - Build everything with formatting
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- `make test` - Run unit tests
|
||||||
|
- `make e2e` - Run end-to-end tests
|
||||||
|
- `make e2e-trace` - Run e2e tests with trace logging
|
||||||
|
- `make alltest` - Run all tests including vet, unit tests, and e2e
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- `make fmt` - Run go fmt
|
||||||
|
- `make fmt-more` - Run gofumpt for more strict formatting
|
||||||
|
- `make gci` - Run gci import organizer
|
||||||
|
- `make vet` - Run go vet
|
||||||
|
- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
- `make web` - Build web dashboards (frps and frpc)
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
- `make clean` - Remove built binaries and temporary files
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- E2E tests using Ginkgo/Gomega framework
|
||||||
|
- Mock servers in `/test/e2e/mock/`
|
||||||
|
- Run: `make e2e` or `make alltest`
|
||||||
|
|
||||||
|
## Agent Runbooks
|
||||||
|
|
||||||
|
Operational procedures for agents are in `doc/agents/`:
|
||||||
|
- `doc/agents/release.md` - Release process
|
||||||
40
README.md
40
README.md
@@ -13,6 +13,16 @@ 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
|
||||||
@@ -40,16 +50,6 @@ 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,6 +81,7 @@ frp also offers a P2P connect mode.
|
|||||||
* [Split Configures Into Different Files](#split-configures-into-different-files)
|
* [Split Configures Into Different Files](#split-configures-into-different-files)
|
||||||
* [Server Dashboard](#server-dashboard)
|
* [Server Dashboard](#server-dashboard)
|
||||||
* [Client Admin UI](#client-admin-ui)
|
* [Client Admin UI](#client-admin-ui)
|
||||||
|
* [Dynamic Proxy Management (Store)](#dynamic-proxy-management-store)
|
||||||
* [Monitor](#monitor)
|
* [Monitor](#monitor)
|
||||||
* [Prometheus](#prometheus)
|
* [Prometheus](#prometheus)
|
||||||
* [Authenticating the Client](#authenticating-the-client)
|
* [Authenticating the Client](#authenticating-the-client)
|
||||||
@@ -149,7 +150,9 @@ We sincerely appreciate your support for frp.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
|
<img src="/doc/pic/architecture.jpg" alt="architecture" width="760">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
@@ -593,7 +596,7 @@ Then visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS conn
|
|||||||
|
|
||||||
### Client Admin UI
|
### Client Admin UI
|
||||||
|
|
||||||
The Client Admin UI helps you check and manage frpc's configuration.
|
The Client Admin UI helps you check and manage frpc's configuration and proxies.
|
||||||
|
|
||||||
Configure an address for admin UI to enable this feature:
|
Configure an address for admin UI to enable this feature:
|
||||||
|
|
||||||
@@ -606,6 +609,19 @@ webServer.password = "admin"
|
|||||||
|
|
||||||
Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`.
|
Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`.
|
||||||
|
|
||||||
|
#### Dynamic Proxy Management (Store)
|
||||||
|
|
||||||
|
You can dynamically create, update, and delete proxies and visitors at runtime through the Web UI or API, without restarting frpc.
|
||||||
|
|
||||||
|
To enable this feature, configure `store.path` to specify a file for persisting the configurations:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[store]
|
||||||
|
path = "./db.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxies and visitors managed through the Store are saved to disk and automatically restored on frpc restart. They work alongside proxies defined in the configuration file — Store entries take precedence when names conflict.
|
||||||
|
|
||||||
### Monitor
|
### Monitor
|
||||||
|
|
||||||
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
||||||
|
|||||||
20
README_zh.md
20
README_zh.md
@@ -15,6 +15,16 @@ 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
|
||||||
@@ -42,16 +52,6 @@ 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 ?
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Added a built-in `store` capability for frpc, including persisted store source (`[store] path = "..."`), Store CRUD admin APIs (`/api/store/proxies*`, `/api/store/visitors*`) with runtime reload, and Store management pages in the frpc web dashboard.
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
|
|
||||||
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
|
|
||||||
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||||
|
subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)
|
||||||
|
subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)
|
||||||
|
|
||||||
if svr.storeSource != nil {
|
if svr.storeSource != nil {
|
||||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
||||||
|
|||||||
@@ -80,6 +80,48 @@ func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
|
|||||||
return m.svr.getAllProxyStatus()
|
return m.svr.getAllProxyStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
// Try running proxy manager first
|
||||||
|
ws, ok := m.svr.getProxyStatus(name)
|
||||||
|
if ok {
|
||||||
|
return ws.Cfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to store
|
||||||
|
m.svr.reloadMu.Lock()
|
||||||
|
storeSource := m.svr.storeSource
|
||||||
|
m.svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
if storeSource != nil {
|
||||||
|
cfg := storeSource.GetProxy(name)
|
||||||
|
if cfg != nil {
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
// Try running visitor manager first
|
||||||
|
cfg, ok := m.svr.getVisitorCfg(name)
|
||||||
|
if ok {
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to store
|
||||||
|
m.svr.reloadMu.Lock()
|
||||||
|
storeSource := m.svr.storeSource
|
||||||
|
m.svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
if storeSource != nil {
|
||||||
|
vcfg := storeSource.GetVisitor(name)
|
||||||
|
if vcfg != nil {
|
||||||
|
return vcfg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
|
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type ConfigManager interface {
|
|||||||
IsStoreProxyEnabled(name string) bool
|
IsStoreProxyEnabled(name string) bool
|
||||||
StoreEnabled() bool
|
StoreEnabled() bool
|
||||||
|
|
||||||
|
GetProxyConfig(name string) (v1.ProxyConfigurer, bool)
|
||||||
|
GetVisitorConfig(name string) (v1.VisitorConfigurer, bool)
|
||||||
|
|
||||||
ListStoreProxies() ([]v1.ProxyConfigurer, error)
|
ListStoreProxies() ([]v1.ProxyConfigurer, error)
|
||||||
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
|
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
|
||||||
CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (c *defaultConnectorImpl) Open() error {
|
|||||||
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
||||||
session, err := fmux.Client(conn, fmuxCfg)
|
session, err := fmux.Client(conn, fmuxCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.muxSession = session
|
c.muxSession = session
|
||||||
|
|||||||
@@ -162,6 +162,44 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
|
|||||||
return psr
|
return psr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProxyConfig handles GET /api/proxy/{name}/config
|
||||||
|
func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := c.manager.GetProxyConfig(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := model.ProxyDefinitionFromConfigurer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVisitorConfig handles GET /api/visitor/{name}/config
|
||||||
|
func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := c.manager.GetVisitorConfig(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := model.VisitorDefinitionFromConfigurer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
||||||
proxies, err := c.manager.ListStoreProxies()
|
proxies, err := c.manager.ListStoreProxies()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type fakeConfigManager struct {
|
|||||||
getProxyStatusFn func() []*proxy.WorkingStatus
|
getProxyStatusFn func() []*proxy.WorkingStatus
|
||||||
isStoreProxyEnabledFn func(name string) bool
|
isStoreProxyEnabledFn func(name string) bool
|
||||||
storeEnabledFn func() bool
|
storeEnabledFn func() bool
|
||||||
|
getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool)
|
||||||
|
getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool)
|
||||||
|
|
||||||
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
||||||
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
||||||
@@ -82,6 +84,20 @@ func (m *fakeConfigManager) StoreEnabled() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
if m.getProxyConfigFn != nil {
|
||||||
|
return m.getProxyConfigFn(name)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
if m.getVisitorConfigFn != nil {
|
||||||
|
return m.getVisitorConfigFn(name)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
||||||
if m.listStoreProxiesFn != nil {
|
if m.listStoreProxiesFn != nil {
|
||||||
return m.listStoreProxiesFn()
|
return m.listStoreProxiesFn()
|
||||||
@@ -529,3 +545,118 @@ func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
|
|||||||
t.Fatalf("unexpected response payload: %#v", payload)
|
t.Fatalf("unexpected response payload: %#v", payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProxyConfigFromManager(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
if name == "ssh" {
|
||||||
|
cfg := &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||||
|
Name: "ssh",
|
||||||
|
Type: "tcp",
|
||||||
|
ProxyBackend: v1.ProxyBackend{
|
||||||
|
LocalPort: 22,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "ssh"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
resp, err := controller.GetProxyConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get proxy config: %v", err)
|
||||||
|
}
|
||||||
|
payload, ok := resp.(model.ProxyDefinition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected response type: %T", resp)
|
||||||
|
}
|
||||||
|
if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil {
|
||||||
|
t.Fatalf("unexpected payload: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProxyConfigNotFound(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
_, err := controller.GetProxyConfig(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
assertHTTPCode(t, err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVisitorConfigFromManager(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
if name == "my-stcp" {
|
||||||
|
cfg := &v1.STCPVisitorConfig{
|
||||||
|
VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||||
|
Name: "my-stcp",
|
||||||
|
Type: "stcp",
|
||||||
|
ServerName: "server1",
|
||||||
|
BindPort: 9000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
resp, err := controller.GetVisitorConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get visitor config: %v", err)
|
||||||
|
}
|
||||||
|
payload, ok := resp.(model.VisitorDefinition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected response type: %T", resp)
|
||||||
|
}
|
||||||
|
if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil {
|
||||||
|
t.Fatalf("unexpected payload: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVisitorConfigNotFound(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
_, err := controller.GetVisitorConfig(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
assertHTTPCode(t, err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -162,15 +163,6 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var webServer *httppkg.Server
|
|
||||||
if options.Common.WebServer.Port > 0 {
|
|
||||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
webServer = ws
|
|
||||||
}
|
|
||||||
|
|
||||||
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
|
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -191,6 +183,17 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||||
|
|
||||||
|
// Create the web server after all fallible steps so its listener is not
|
||||||
|
// leaked when an earlier error causes NewService to return.
|
||||||
|
var webServer *httppkg.Server
|
||||||
|
if options.Common.WebServer.Port > 0 {
|
||||||
|
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webServer = ws
|
||||||
|
}
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
@@ -229,22 +232,25 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if svr.vnetController != nil {
|
if svr.vnetController != nil {
|
||||||
|
vnetController := svr.vnetController
|
||||||
if err := svr.vnetController.Init(); err != nil {
|
if err := svr.vnetController.Init(); err != nil {
|
||||||
log.Errorf("init virtual network controller error: %v", err)
|
log.Errorf("init virtual network controller error: %v", err)
|
||||||
|
svr.stop()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("virtual network controller start...")
|
log.Infof("virtual network controller start...")
|
||||||
if err := svr.vnetController.Run(); err != nil {
|
if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
log.Warnf("virtual network controller exit with error: %v", err)
|
log.Warnf("virtual network controller exit with error: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if svr.webServer != nil {
|
if svr.webServer != nil {
|
||||||
|
webServer := svr.webServer
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("admin server listen on %s", svr.webServer.Address())
|
log.Infof("admin server listen on %s", webServer.Address())
|
||||||
if err := svr.webServer.Run(); err != nil {
|
if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Warnf("admin server exit with error: %v", err)
|
log.Warnf("admin server exit with error: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -255,6 +261,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
if svr.ctl == nil {
|
if svr.ctl == nil {
|
||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||||
|
svr.stop()
|
||||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +504,10 @@ func (svr *Service) stop() {
|
|||||||
svr.webServer.Close()
|
svr.webServer.Close()
|
||||||
svr.webServer = nil
|
svr.webServer = nil
|
||||||
}
|
}
|
||||||
|
if svr.vnetController != nil {
|
||||||
|
_ = svr.vnetController.Stop()
|
||||||
|
svr.vnetController = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||||
@@ -510,6 +521,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
|||||||
return ctl.pm.GetProxyStatus(name)
|
return ctl.pm.GetProxyStatus(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
svr.ctlMu.RLock()
|
||||||
|
ctl := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
|
||||||
|
if ctl == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ctl.vm.GetVisitorCfg(name)
|
||||||
|
}
|
||||||
|
|
||||||
func (svr *Service) StatusExporter() StatusExporter {
|
func (svr *Service) StatusExporter() StatusExporter {
|
||||||
return &statusExporterImpl{
|
return &statusExporterImpl{
|
||||||
getProxyStatusFunc: svr.getProxyStatus,
|
getProxyStatusFunc: svr.getProxyStatus,
|
||||||
|
|||||||
@@ -1,14 +1,120 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type failingConnector struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Open() error {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Connect() (net.Conn, error) {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFreeTCPPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen on ephemeral port: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
||||||
|
port := getFreeTCPPort(t)
|
||||||
|
agg := source.NewAggregator(source.NewConfigSource())
|
||||||
|
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
LoginFailExit: lo.ToPtr(true),
|
||||||
|
WebServer: v1.WebServerConfig{
|
||||||
|
Addr: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: agg,
|
||||||
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
return &failingConnector{err: errors.New("login boom")}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svr.Run(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected run error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "login boom") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if svr.webServer != nil {
|
||||||
|
t.Fatal("expected web server to be cleaned up after initial login failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected admin port to be released: %v", err)
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
|
||||||
|
port := getFreeTCPPort(t)
|
||||||
|
agg := source.NewAggregator(source.NewConfigSource())
|
||||||
|
|
||||||
|
_, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
Auth: v1.AuthClientConfig{
|
||||||
|
Method: v1.AuthMethodOIDC,
|
||||||
|
OIDC: v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WebServer: v1.WebServerConfig{
|
||||||
|
Addr: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: agg,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected new service error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected admin port to remain free: %v", err)
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
|
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
|
||||||
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
|
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
|
||||||
newCommon := &v1.ClientCommonConfig{User: "new-user"}
|
newCommon := &v1.ClientCommonConfig{User: "new-user"}
|
||||||
|
|||||||
@@ -191,6 +191,13 @@ 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
|
||||||
|
|||||||
80
doc/agents/release.md
Normal file
80
doc/agents/release.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 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`)
|
||||||
BIN
doc/pic/architecture.jpg
Normal file
BIN
doc/pic/architecture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,8 +1,11 @@
|
|||||||
FROM node:22 AS web-builder
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
WORKDIR /web/frpc
|
COPY web/package.json /web/package.json
|
||||||
COPY web/frpc/ ./
|
COPY web/shared/ /web/shared/
|
||||||
|
COPY web/frpc/ /web/frpc/
|
||||||
|
WORKDIR /web
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
WORKDIR /web/frpc
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
FROM node:22 AS web-builder
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
WORKDIR /web/frps
|
COPY web/package.json /web/package.json
|
||||||
COPY web/frps/ ./
|
COPY web/shared/ /web/shared/
|
||||||
|
COPY web/frps/ /web/frps/
|
||||||
|
WORKDIR /web
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
WORKDIR /web/frps
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|||||||
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.5.1
|
github.com/fatedier/golib v0.6.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.36.3
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.2.0
|
||||||
github.com/pion/stun/v2 v2.0.0
|
github.com/pion/stun/v3 v3.1.1
|
||||||
github.com/pires/go-proxyproto v0.7.0
|
github.com/pires/go-proxyproto v0.7.0
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/quic-go/quic-go v0.55.0
|
github.com/quic-go/quic-go v0.55.0
|
||||||
@@ -22,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.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tidwall/gjson v1.17.1
|
github.com/tidwall/gjson v1.17.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
golang.org/x/sync v0.16.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/time v0.5.0
|
golang.org/x/time v0.10.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
k8s.io/apimachinery v0.28.8
|
k8s.io/apimachinery v0.28.8
|
||||||
@@ -38,7 +38,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -51,10 +51,9 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||||
github.com/pion/logging v0.2.2 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.1 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
@@ -66,11 +65,12 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.42.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.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
|
github.com/fatedier/golib v0.6.0 h1:/mgBZZbkbMhIEZoXf7nV8knpUDzas/b+2ruYKxx1lww=
|
||||||
github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
|
github.com/fatedier/golib v0.6.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -78,16 +78,14 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
|||||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
|
||||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
|
||||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -128,11 +126,10 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
||||||
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||||
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
||||||
@@ -149,11 +146,12 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
|||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||||
@@ -161,89 +159,54 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
|
||||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,14 +76,64 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
|
|||||||
return &http.Client{Transport: transport}, nil
|
return &http.Client{Transport: transport}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
|
||||||
|
// token on every call. This is used as a fallback when the OIDC provider
|
||||||
|
// does not return expires_in, which would cause a caching TokenSource to
|
||||||
|
// hold onto a stale token forever.
|
||||||
|
type nonCachingTokenSource struct {
|
||||||
|
cfg *clientcredentials.Config
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
return s.cfg.Token(s.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
|
||||||
|
// successful Token() call, checks whether the provider returns an expiry.
|
||||||
|
// If not, it permanently switches to nonCachingTokenSource so that a fresh
|
||||||
|
// token is fetched every time. This avoids an eager network call at
|
||||||
|
// construction time, letting the login retry loop handle transient IdP
|
||||||
|
// outages.
|
||||||
|
type oidcTokenSource struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
initialized bool
|
||||||
|
source oauth2.TokenSource
|
||||||
|
fallbackCfg *clientcredentials.Config
|
||||||
|
fallbackCtx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.initialized {
|
||||||
|
token, err := s.source.Token()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if token.Expiry.IsZero() {
|
||||||
|
s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
|
||||||
|
}
|
||||||
|
s.initialized = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
source := s.source
|
||||||
|
s.mu.Unlock()
|
||||||
|
return source.Token()
|
||||||
|
}
|
||||||
|
|
||||||
type OidcAuthProvider struct {
|
type OidcAuthProvider struct {
|
||||||
additionalAuthScopes []v1.AuthScope
|
additionalAuthScopes []v1.AuthScope
|
||||||
|
|
||||||
tokenGenerator *clientcredentials.Config
|
tokenSource oauth2.TokenSource
|
||||||
httpClient *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
||||||
|
if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
eps := make(map[string][]string)
|
eps := make(map[string][]string)
|
||||||
for k, v := range cfg.AdditionalEndpointParams {
|
for k, v := range cfg.AdditionalEndpointParams {
|
||||||
eps[k] = []string{v}
|
eps[k] = []string{v}
|
||||||
@@ -100,30 +151,42 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
|
|||||||
EndpointParams: eps,
|
EndpointParams: eps,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create custom HTTP client if needed
|
// Build the context that TokenSource will use for all future HTTP requests.
|
||||||
var httpClient *http.Client
|
// context.Background() is appropriate here because the token source is
|
||||||
|
// long-lived and outlives any single request.
|
||||||
|
ctx := context.Background()
|
||||||
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
||||||
var err error
|
httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
||||||
httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a persistent TokenSource that caches the token and refreshes
|
||||||
|
// it before expiry. This avoids making a new HTTP request to the OIDC
|
||||||
|
// provider on every heartbeat/ping.
|
||||||
|
//
|
||||||
|
// We wrap it in an oidcTokenSource so that the first Token() call
|
||||||
|
// (deferred to SetLogin inside the login retry loop) probes whether the
|
||||||
|
// provider returns expires_in. If not, it switches to a non-caching
|
||||||
|
// source. This avoids an eager network call at construction time, which
|
||||||
|
// would prevent loopLoginUntilSuccess from retrying on transient IdP
|
||||||
|
// outages.
|
||||||
|
cachingSource := tokenGenerator.TokenSource(ctx)
|
||||||
|
|
||||||
return &OidcAuthProvider{
|
return &OidcAuthProvider{
|
||||||
additionalAuthScopes: additionalAuthScopes,
|
additionalAuthScopes: additionalAuthScopes,
|
||||||
tokenGenerator: tokenGenerator,
|
tokenSource: &oidcTokenSource{
|
||||||
httpClient: httpClient,
|
source: cachingSource,
|
||||||
|
fallbackCfg: tokenGenerator,
|
||||||
|
fallbackCtx: ctx,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
||||||
ctx := context.Background()
|
tokenObj, err := auth.tokenSource.Token()
|
||||||
if auth.httpClient != nil {
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenObj, err := auth.tokenGenerator.Token(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package auth_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -62,3 +66,188 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
|
|||||||
r.Error(err)
|
r.Error(err)
|
||||||
r.Contains(err.Error(), "received different OIDC subject in login and ping")
|
r.Contains(err.Error(), "received different OIDC subject in login and ping")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
requestCount.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "fresh-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
|
||||||
|
// Constructor no longer fetches a token eagerly.
|
||||||
|
// The first SetLogin triggers the adaptive probe.
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
|
||||||
|
r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderCachesToken(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
requestCount.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "cached-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
|
||||||
|
// Constructor no longer fetches eagerly; first SetLogin triggers the probe.
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
// SetLogin triggers the adaptive probe and caches the token.
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("cached-test-token", loginMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(1), requestCount.Load())
|
||||||
|
|
||||||
|
// Subsequent calls should also reuse the cached token
|
||||||
|
for range 5 {
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("cached-test-token", pingMsg.PrivilegeKey)
|
||||||
|
}
|
||||||
|
r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
n := requestCount.Add(1)
|
||||||
|
// The oauth2 library retries once internally, so we need two
|
||||||
|
// consecutive failures to surface an error to the caller.
|
||||||
|
if n <= 2 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": "temporarily_unavailable",
|
||||||
|
"error_description": "service is starting up",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "retry-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
// Constructor succeeds even though the IdP is "down".
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
// First SetLogin hits the IdP, which returns an error (after internal retry).
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.Error(err)
|
||||||
|
r.Equal(int32(2), requestCount.Load())
|
||||||
|
|
||||||
|
// Second SetLogin retries and succeeds.
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("retry-test-token", loginMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(3), requestCount.Load())
|
||||||
|
|
||||||
|
// Subsequent calls use cached token.
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("retry-test-token", pingMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(3), requestCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
_, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.clientID is required")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"scope": "profile",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
Audience: "api",
|
||||||
|
AdditionalEndpointParams: map[string]string{"audience": "override"},
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
pkg/config/v1/validation/oidc.go
Normal file
57
pkg/config/v1/validation/oidc.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if c.ClientID == "" {
|
||||||
|
errs = append(errs, "auth.oidc.clientID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.TokenEndpointURL == "" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL is required")
|
||||||
|
} else {
|
||||||
|
tokenURL, err := url.Parse(c.TokenEndpointURL)
|
||||||
|
if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
|
||||||
|
} else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := c.AdditionalEndpointParams["scope"]; ok {
|
||||||
|
errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Audience != "" {
|
||||||
|
if _, ok := c.AdditionalEndpointParams["audience"]; ok {
|
||||||
|
errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
78
pkg/config/v1/validation/oidc_test.go
Normal file
78
pkg/config/v1/validation/oidc_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"resource": "api",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid token endpoint url", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing client id", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.clientID is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scope endpoint param is not allowed", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"scope": "email",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("audience conflict", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
Audience: "api",
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"audience": "override",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/stun/v2"
|
"github.com/pion/stun/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var responseTimeout = 3 * time.Second
|
var responseTimeout = 3 * time.Second
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/pion/stun/v2"
|
"github.com/pion/stun/v3"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ func (s *Server) Run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Close() error {
|
func (s *Server) Close() error {
|
||||||
return s.hs.Close()
|
err := s.hs.Close()
|
||||||
|
if s.ln != nil {
|
||||||
|
_ = s.ln.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouterRegisterHelper struct {
|
type RouterRegisterHelper struct {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ 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,6 +241,31 @@ 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,6 +9,7 @@ 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"
|
||||||
@@ -61,9 +62,22 @@ func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string
|
|||||||
err = p.Start()
|
err = p.Start()
|
||||||
ExpectNoError(err)
|
ExpectNoError(err)
|
||||||
}
|
}
|
||||||
// frpc needs time to connect and register proxies with frps.
|
// Wait for each client's proxies to register with frps.
|
||||||
if len(clientProcesses) > 0 {
|
// If any client has no proxies (e.g. visitor-only), fall back to sleep
|
||||||
time.Sleep(1500 * time.Millisecond)
|
// for the remaining time since visitors have no deterministic readiness signal.
|
||||||
|
allConfirmed := len(clientProcesses) > 0
|
||||||
|
start := time.Now()
|
||||||
|
for i, p := range clientProcesses {
|
||||||
|
configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
|
||||||
|
if !waitForClientProxyReady(configPath, p, 5*time.Second) {
|
||||||
|
allConfirmed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clientProcesses) > 0 && !allConfirmed {
|
||||||
|
remaining := 1500*time.Millisecond - time.Since(start)
|
||||||
|
if remaining > 0 {
|
||||||
|
time.Sleep(remaining)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return serverProcess, clientProcesses
|
return serverProcess, clientProcesses
|
||||||
@@ -105,6 +119,55 @@ 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,6 +20,8 @@ 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
|
||||||
@@ -37,8 +39,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 = 11001
|
remote_port = %d
|
||||||
`, framework.TCPEchoServerPort)
|
`, framework.TCPEchoServerPort, tcpPortNotAllowed)
|
||||||
clientConf += fmt.Sprintf(`
|
clientConf += fmt.Sprintf(`
|
||||||
[tcp-port-unavailable]
|
[tcp-port-unavailable]
|
||||||
type = tcp
|
type = tcp
|
||||||
@@ -55,8 +57,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 = 11003
|
remote_port = %d
|
||||||
`, framework.UDPEchoServerPort)
|
`, framework.UDPEchoServerPort, udpPortNotAllowed)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
@@ -65,7 +67,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(11001).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).Port(tcpPortNotAllowed).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()
|
||||||
@@ -76,7 +78,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(11003)
|
r.UDP().Port(udpPortNotAllowed)
|
||||||
}).ExpectError(true).Ensure()
|
}).ExpectError(true).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
258
test/e2e/mock/server/oidcserver/oidcserver.go
Normal file
258
test/e2e/mock/server/oidcserver/oidcserver.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// 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,15 +4,37 @@ 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 *bytes.Buffer
|
errorOutput *SafeBuffer
|
||||||
stdOutput *bytes.Buffer
|
stdOutput *SafeBuffer
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOne sync.Once
|
closeOne sync.Once
|
||||||
@@ -36,8 +58,8 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
p.errorOutput = bytes.NewBufferString("")
|
p.errorOutput = &SafeBuffer{}
|
||||||
p.stdOutput = bytes.NewBufferString("")
|
p.stdOutput = &SafeBuffer{}
|
||||||
cmd.Stderr = p.errorOutput
|
cmd.Stderr = p.errorOutput
|
||||||
cmd.Stdout = p.stdOutput
|
cmd.Stdout = p.stdOutput
|
||||||
return p
|
return p
|
||||||
@@ -98,6 +120,34 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
192
test/e2e/v1/basic/oidc.go
Normal file
192
test/e2e/v1/basic/oidc.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package 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,6 +20,8 @@ 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 = [
|
||||||
@@ -43,8 +45,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 = 11001
|
remotePort = %d
|
||||||
`, framework.TCPEchoServerPort)
|
`, framework.TCPEchoServerPort, tcpPortNotAllowed)
|
||||||
clientConf += fmt.Sprintf(`
|
clientConf += fmt.Sprintf(`
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "tcp-port-unavailable"
|
name = "tcp-port-unavailable"
|
||||||
@@ -64,8 +66,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 = 11003
|
remotePort = %d
|
||||||
`, framework.UDPEchoServerPort)
|
`, framework.UDPEchoServerPort, udpPortNotAllowed)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
@@ -74,7 +76,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(11001).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).Port(tcpPortNotAllowed).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()
|
||||||
@@ -85,7 +87,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(11003)
|
r.UDP().Port(udpPortNotAllowed)
|
||||||
}).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)
|
||||||
time.Sleep(2 * time.Second)
|
framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*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()
|
||||||
time.Sleep(200 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
|
||||||
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
|
||||||
_, _, err = f.RunFrpc("-c", clientConfigPath)
|
newPc, _, err := f.RunFrpc("-c", clientConfigPath)
|
||||||
framework.ExpectNoError(err)
|
framework.ExpectNoError(err)
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*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)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
// check foo and bar is ok
|
// check foo and bar is ok
|
||||||
results := []string{}
|
results := []string{}
|
||||||
@@ -299,15 +299,17 @@ 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()
|
||||||
time.Sleep(2 * time.Second)
|
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*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)
|
||||||
time.Sleep(2 * time.Second)
|
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*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 {
|
||||||
@@ -357,7 +359,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
healthCheck.path = "/healthz"
|
healthCheck.path = "/healthz"
|
||||||
`, fooPort, barPort)
|
`, fooPort, barPort)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
// send first HTTP request
|
// send first HTTP request
|
||||||
var contents []string
|
var contents []string
|
||||||
@@ -387,15 +389,17 @@ 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()
|
||||||
time.Sleep(2 * time.Second)
|
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*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)
|
||||||
time.Sleep(2 * time.Second)
|
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*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})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*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})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*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,7 +107,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
return resp.Code == 200
|
return resp.Code == 200
|
||||||
})
|
})
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second))
|
||||||
|
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
|
||||||
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
|
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
|
||||||
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
|
||||||
})
|
})
|
||||||
@@ -126,7 +127,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
proxyConfig := map[string]any{
|
||||||
"name": "test-tcp",
|
"name": "test-tcp",
|
||||||
@@ -147,7 +148,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
return resp.Code == 200
|
return resp.Code == 200
|
||||||
})
|
})
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*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) {
|
||||||
@@ -156,7 +157,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
return resp.Code == 200
|
return resp.Code == 200
|
||||||
})
|
})
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
|
||||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
proxyConfig := map[string]any{
|
proxyConfig := map[string]any{
|
||||||
"name": "test-tcp",
|
"name": "test-tcp",
|
||||||
@@ -195,8 +196,6 @@ 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 {
|
||||||
@@ -226,7 +225,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort)
|
`, adminPort)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
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")
|
||||||
@@ -248,7 +247,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
invalidBody, _ := json.Marshal(map[string]any{
|
invalidBody, _ := json.Marshal(map[string]any{
|
||||||
"name": "bad-proxy",
|
"name": "bad-proxy",
|
||||||
@@ -281,7 +280,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
|||||||
`, adminPort, f.TempDirectory)
|
`, adminPort, f.TempDirectory)
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
time.Sleep(500 * time.Millisecond)
|
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||||
|
|
||||||
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:
|
||||||
@npm install
|
@cd .. && 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,28 +7,39 @@ export {}
|
|||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ConfigField: typeof import('./src/components/ConfigField.vue')['default']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
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']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
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']
|
||||||
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
StatusPills: typeof import('./src/components/StatusPills.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,6 +3,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
7646
web/frpc/package-lock.json
generated
7646
web/frpc/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"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,140 +2,160 @@
|
|||||||
<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">
|
||||||
<div class="logo-wrapper">
|
<span class="hamburger-icon">☰</span>
|
||||||
<LogoIcon class="logo-icon" />
|
</button>
|
||||||
</div>
|
<div class="logo-wrapper">
|
||||||
<span class="divider">/</span>
|
<LogoIcon class="logo-icon" />
|
||||||
<span class="brand-name">frp</span>
|
|
||||||
<span class="badge client-badge">Client</span>
|
|
||||||
<span class="badge" v-if="currentRouteName">{{
|
|
||||||
currentRouteName
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-controls">
|
|
||||||
<a
|
|
||||||
class="github-link"
|
|
||||||
href="https://github.com/fatedier/frp"
|
|
||||||
target="_blank"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<GitHubIcon class="github-icon" />
|
|
||||||
</a>
|
|
||||||
<el-switch
|
|
||||||
v-model="isDark"
|
|
||||||
inline-prompt
|
|
||||||
:active-icon="Moon"
|
|
||||||
:inactive-icon="Sunny"
|
|
||||||
class="theme-switch"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="brand-name">frp</span>
|
||||||
|
<span class="badge">Client</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-bar">
|
<div class="header-controls">
|
||||||
<router-link to="/" class="nav-link" active-class="active"
|
<a
|
||||||
>Overview</router-link
|
class="github-link"
|
||||||
|
href="https://github.com/fatedier/frp"
|
||||||
|
target="_blank"
|
||||||
|
aria-label="GitHub"
|
||||||
>
|
>
|
||||||
<router-link to="/configure" class="nav-link" active-class="active"
|
<GitHubIcon class="github-icon" />
|
||||||
>Configure</router-link
|
</a>
|
||||||
>
|
<el-switch
|
||||||
</nav>
|
v-model="isDark"
|
||||||
|
inline-prompt
|
||||||
|
:active-icon="Moon"
|
||||||
|
:inactive-icon="Sunny"
|
||||||
|
class="theme-switch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="content">
|
<div class="layout">
|
||||||
<router-view></router-view>
|
<!-- Mobile overlay -->
|
||||||
</main>
|
<div
|
||||||
|
v-if="isMobile && sidebarOpen"
|
||||||
|
class="sidebar-overlay"
|
||||||
|
@click="closeSidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside class="sidebar" :class="{ 'mobile-open': isMobile && sidebarOpen }">
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<router-link
|
||||||
|
to="/proxies"
|
||||||
|
class="sidebar-link"
|
||||||
|
:class="{ active: route.path.startsWith('/proxies') }"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
Proxies
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/visitors"
|
||||||
|
class="sidebar-link"
|
||||||
|
:class="{ active: route.path.startsWith('/visitors') }"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
Visitors
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/config"
|
||||||
|
class="sidebar-link"
|
||||||
|
:class="{ active: route.path === '/config' }"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
Config
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="content">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, watch } 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 currentRouteName = computed(() => {
|
const sidebarOpen = ref(false)
|
||||||
if (route.path === '/') return 'Overview'
|
|
||||||
if (route.path === '/configure') return 'Configure'
|
const toggleSidebar = () => {
|
||||||
if (route.path === '/proxies/create') return 'Create Proxy'
|
sidebarOpen.value = !sidebarOpen.value
|
||||||
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
|
}
|
||||||
return 'Edit Proxy'
|
|
||||||
if (route.path === '/visitors/create') return 'Create Visitor'
|
const closeSidebar = () => {
|
||||||
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
|
sidebarOpen.value = false
|
||||||
return 'Edit Visitor'
|
}
|
||||||
return ''
|
|
||||||
|
// Auto-close sidebar on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
closeSidebar()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
: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:
|
font-family: ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica,
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
|
||||||
Arial, sans-serif;
|
Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
*,
|
||||||
min-height: 100vh;
|
:after,
|
||||||
display: flex;
|
:before {
|
||||||
flex-direction: column;
|
box-sizing: border-box;
|
||||||
background-color: var(--el-bg-color-page);
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: $color-bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
.header {
|
.header {
|
||||||
position: sticky;
|
flex-shrink: 0;
|
||||||
top: 0;
|
background: $color-bg-primary;
|
||||||
z-index: 100;
|
border-bottom: 1px solid $color-border-light;
|
||||||
background: var(--header-bg);
|
height: $header-height;
|
||||||
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: 12px;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
@@ -144,41 +164,30 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
color: var(--header-border);
|
color: $color-border;
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font-weight: 600;
|
font-weight: $font-weight-semibold;
|
||||||
font-size: 18px;
|
font-size: $font-size-xl;
|
||||||
color: var(--text-primary);
|
color: $color-text-primary;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 12px;
|
font-size: $font-size-xs;
|
||||||
color: var(--text-secondary);
|
font-weight: $font-weight-medium;
|
||||||
background: var(--hover-bg);
|
color: $color-text-muted;
|
||||||
|
background: $color-bg-muted;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 99px;
|
border-radius: 4px;
|
||||||
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 {
|
||||||
@@ -188,17 +197,17 @@ html.dark .badge.client-badge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.github-link {
|
.github-link {
|
||||||
width: 26px;
|
@include flex-center;
|
||||||
height: 26px;
|
width: 28px;
|
||||||
display: flex;
|
height: 28px;
|
||||||
align-items: center;
|
border-radius: $radius-sm;
|
||||||
justify-content: center;
|
color: $color-text-secondary;
|
||||||
border-radius: 50%;
|
transition: all $transition-fast;
|
||||||
color: var(--text-primary);
|
|
||||||
transition: background 0.2s;
|
&:hover {
|
||||||
background: transparent;
|
background: $color-bg-hover;
|
||||||
border: 1px solid transparent;
|
color: $color-text-primary;
|
||||||
cursor: pointer;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.github-icon {
|
.github-icon {
|
||||||
@@ -206,15 +215,10 @@ html.dark .badge.client-badge {
|
|||||||
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(--header-border);
|
--el-switch-border-color: var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .theme-switch {
|
html.dark .theme-switch {
|
||||||
@@ -225,47 +229,300 @@ html.dark .theme-switch {
|
|||||||
color: #909399 !important;
|
color: #909399 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-bar {
|
// Layout
|
||||||
height: 48px;
|
.layout {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
gap: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.sidebar {
|
||||||
|
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: 14px;
|
font-size: $font-size-lg;
|
||||||
color: var(--text-secondary);
|
color: $color-text-secondary;
|
||||||
padding: 8px 0;
|
padding: 10px $spacing-md;
|
||||||
border-bottom: 2px solid transparent;
|
border-radius: $radius-sm;
|
||||||
transition: all 0.2s;
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text-primary;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $color-text-primary;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
// Hamburger button (mobile only)
|
||||||
color: var(--text-primary);
|
.hamburger-btn {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.hamburger-icon {
|
||||||
color: var(--active-link);
|
font-size: 20px;
|
||||||
border-bottom-color: var(--active-link);
|
line-height: 1;
|
||||||
|
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;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
padding: 40px;
|
overflow: hidden;
|
||||||
max-width: 1200px;
|
background: $color-bg-primary;
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
// Common page styles
|
||||||
|
.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 20px;
|
padding: 0 $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
padding: 20px;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/proxy'
|
} from '../types'
|
||||||
|
|
||||||
export const getStatus = () => {
|
export const getStatus = () => {
|
||||||
return http.get<StatusResponse>('/api/status')
|
return http.get<StatusResponse>('/api/status')
|
||||||
@@ -23,6 +23,19 @@ 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')
|
||||||
|
|||||||
33
web/frpc/src/assets/css/_form-layout.scss
Normal file
33
web/frpc/src/assets/css/_form-layout.scss
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/* 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,48 +1,51 @@
|
|||||||
/* Dark Mode Theme */
|
/* Dark mode styles */
|
||||||
html.dark {
|
html.dark {
|
||||||
--el-bg-color: #1e1e2e;
|
--el-bg-color: #212121;
|
||||||
--el-bg-color-page: #1a1a2e;
|
--el-bg-color-page: #181818;
|
||||||
--el-bg-color-overlay: #27293d;
|
--el-bg-color-overlay: #303030;
|
||||||
--el-fill-color-blank: #1e1e2e;
|
--el-fill-color-blank: #212121;
|
||||||
background-color: #1a1a2e;
|
--el-border-color: #404040;
|
||||||
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark body {
|
/* Scrollbar */
|
||||||
background-color: #1a1a2e;
|
html.dark ::-webkit-scrollbar {
|
||||||
color: #e5e7eb;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar */
|
|
||||||
html.dark ::-webkit-scrollbar-track {
|
html.dark ::-webkit-scrollbar-track {
|
||||||
background: #27293d;
|
background: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark ::-webkit-scrollbar-thumb {
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
background: #3a3d5c;
|
background: #404040;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4a4d6c;
|
background: #505050;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode cards */
|
/* Form */
|
||||||
html.dark .el-card {
|
html.dark .el-form-item__label {
|
||||||
background-color: #27293d;
|
color: #e5e7eb;
|
||||||
border-color: #3a3d5c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-card__header {
|
/* Input */
|
||||||
border-bottom-color: #3a3d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode inputs */
|
|
||||||
html.dark .el-input__wrapper {
|
html.dark .el-input__wrapper {
|
||||||
background-color: #27293d;
|
background: var(--color-bg-input);
|
||||||
box-shadow: 0 0 0 1px #3a3d5c inset;
|
box-shadow: 0 0 0 1px #404040 inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-input__wrapper:hover {
|
html.dark .el-input__wrapper:hover {
|
||||||
box-shadow: 0 0 0 1px #4a4d6c inset;
|
box-shadow: 0 0 0 1px #505050 inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-input__wrapper.is-focus {
|
html.dark .el-input__wrapper.is-focus {
|
||||||
@@ -54,71 +57,44 @@ html.dark .el-input__inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-input__inner::placeholder {
|
html.dark .el-input__inner::placeholder {
|
||||||
color: #6b7280;
|
color: #afafaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode textarea */
|
|
||||||
html.dark .el-textarea__inner {
|
html.dark .el-textarea__inner {
|
||||||
background-color: #1e1e2d;
|
background: var(--color-bg-input);
|
||||||
border-color: #3a3d5c;
|
box-shadow: 0 0 0 1px #404040 inset;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-textarea__inner::placeholder {
|
html.dark .el-textarea__inner:hover {
|
||||||
color: #6b7280;
|
box-shadow: 0 0 0 1px #505050 inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode table */
|
html.dark .el-textarea__inner:focus {
|
||||||
html.dark .el-table {
|
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||||
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-table th.el-table__cell {
|
html.dark .el-select__placeholder {
|
||||||
background-color: #1e1e2e;
|
color: #afafaf;
|
||||||
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-color: #27293d;
|
background: #303030;
|
||||||
border-color: #3a3d5c;
|
border-color: #404040;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-select-dropdown__item {
|
html.dark .el-select-dropdown__item {
|
||||||
@@ -126,55 +102,92 @@ html.dark .el-select-dropdown__item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-select-dropdown__item:hover {
|
html.dark .el-select-dropdown__item:hover {
|
||||||
background-color: #2a2a3c;
|
background: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode dialog */
|
html.dark .el-select-dropdown__item.is-selected {
|
||||||
|
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-color: #27293d;
|
background: #212121;
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .el-dialog__header {
|
|
||||||
border-bottom-color: #3a3d5c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-dialog__title {
|
html.dark .el-dialog__title {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-dialog__body {
|
/* Message */
|
||||||
color: #e5e7eb;
|
html.dark .el-message {
|
||||||
|
background: #303030;
|
||||||
|
border-color: #404040;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode message box */
|
html.dark .el-message--success {
|
||||||
html.dark .el-message-box {
|
background: #1e3d2e;
|
||||||
background-color: #27293d;
|
border-color: #3d6b4f;
|
||||||
border-color: #3a3d5c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-message-box__title {
|
html.dark .el-message--warning {
|
||||||
color: #e5e7eb;
|
background: #3d3020;
|
||||||
|
border-color: #6b5020;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-message-box__message {
|
html.dark .el-message--error {
|
||||||
color: #e5e7eb;
|
background: #3d2027;
|
||||||
|
border-color: #5c2d2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode empty */
|
/* Loading */
|
||||||
html.dark .el-empty__description {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode loading */
|
|
||||||
html.dark .el-loading-mask {
|
html.dark .el-loading-mask {
|
||||||
background-color: rgba(30, 30, 46, 0.9);
|
background-color: rgba(33, 33, 33, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .el-loading-text {
|
/* Overlay */
|
||||||
color: #e5e7eb;
|
html.dark .el-overlay {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode tooltip */
|
/* Tooltip */
|
||||||
html.dark .el-tooltip__trigger {
|
html.dark .el-tooltip__popper {
|
||||||
color: #e5e7eb;
|
background: #303030 !important;
|
||||||
|
border-color: #404040 !important;
|
||||||
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
|
|||||||
117
web/frpc/src/assets/css/var.css
Normal file
117
web/frpc/src/assets/css/var.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
249
web/frpc/src/components/ConfigField.vue
Normal file
249
web/frpc/src/components/ConfigField.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<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>
|
||||||
185
web/frpc/src/components/ConfigSection.vue
Normal file
185
web/frpc/src/components/ConfigSection.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<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,42 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="kv-editor">
|
<div class="kv-editor">
|
||||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
<template v-if="readonly">
|
||||||
<el-input
|
<div v-if="modelValue.length === 0" class="kv-empty">—</div>
|
||||||
:model-value="entry.key"
|
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row">
|
||||||
:placeholder="keyPlaceholder"
|
<span class="kv-readonly-key">{{ entry.key }}</span>
|
||||||
class="kv-input"
|
<span class="kv-readonly-value">{{ entry.value }}</span>
|
||||||
@update:model-value="updateEntry(index, 'key', $event)"
|
</div>
|
||||||
/>
|
</template>
|
||||||
<el-input
|
<template v-else>
|
||||||
:model-value="entry.value"
|
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
||||||
:placeholder="valuePlaceholder"
|
<el-input
|
||||||
class="kv-input"
|
:model-value="entry.key"
|
||||||
@update:model-value="updateEntry(index, 'value', $event)"
|
:placeholder="keyPlaceholder"
|
||||||
/>
|
class="kv-input"
|
||||||
<button class="kv-remove-btn" @click="removeEntry(index)">
|
@update:model-value="updateEntry(index, 'key', $event)"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
:model-value="entry.value"
|
||||||
|
:placeholder="valuePlaceholder"
|
||||||
|
class="kv-input"
|
||||||
|
@update:model-value="updateEntry(index, 'value', $event)"
|
||||||
|
/>
|
||||||
|
<button class="kv-remove-btn" @click="removeEntry(index)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="kv-add-btn" @click="addEntry">
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="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"
|
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"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
<button class="kv-add-btn" @click="addEntry">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -50,11 +59,13 @@ 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<{
|
||||||
@@ -129,25 +140,45 @@ html.dark .kv-remove-btn:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 14px;
|
padding: 5px 12px;
|
||||||
border: 1px dashed var(--el-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.15s;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-add-btn svg {
|
.kv-add-btn svg {
|
||||||
width: 14px;
|
width: 13px;
|
||||||
height: 14px;
|
height: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-add-btn:hover {
|
.kv-add-btn:hover {
|
||||||
color: var(--el-color-primary);
|
background: var(--color-bg-hover);
|
||||||
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,104 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="proxy-card" :class="{ 'has-error': proxy.err }" @click="$emit('click', proxy)">
|
||||||
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" :class="`type-${proxy.type}`">{{
|
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||||
proxy.type.toUpperCase()
|
<span class="status-pill" :class="statusClass">
|
||||||
}}</span>
|
<span class="status-dot"></span>
|
||||||
<span v-if="isStore" class="source-tag">
|
{{ proxy.status }}
|
||||||
<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">
|
||||||
<div class="card-meta">
|
<template v-if="proxy.remote_addr && localDisplay">
|
||||||
<span v-if="proxy.local_addr" class="meta-item">
|
{{ proxy.remote_addr }} → {{ localDisplay }}
|
||||||
<span class="meta-label">Local</span>
|
</template>
|
||||||
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
<template v-else-if="proxy.remote_addr">{{ proxy.remote_addr }}</template>
|
||||||
</span>
|
<template v-else-if="localDisplay">{{ localDisplay }}</template>
|
||||||
<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">
|
||||||
<div v-if="proxy.err" class="error-info">
|
<span v-if="showSource" class="source-label">{{ displaySource }}</span>
|
||||||
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
|
<div v-if="showActions" @click.stop>
|
||||||
<div class="error-badge">
|
<PopoverMenu :width="120" placement="bottom-end">
|
||||||
<el-icon class="error-icon"><Warning /></el-icon>
|
<template #trigger>
|
||||||
<span class="error-text">Error</span>
|
<ActionButton variant="outline" size="small">
|
||||||
</div>
|
<el-icon><MoreFilled /></el-icon>
|
||||||
</el-tooltip>
|
</ActionButton>
|
||||||
</div>
|
</template>
|
||||||
|
<PopoverMenuItem v-if="proxy.status === 'disabled'" @click="$emit('toggle', proxy, true)">
|
||||||
<div class="status-badge" :class="statusClass">
|
<el-icon><Open /></el-icon>
|
||||||
<span class="status-dot"></span>
|
Enable
|
||||||
{{ proxy.status }}
|
</PopoverMenuItem>
|
||||||
</div>
|
<PopoverMenuItem v-else @click="$emit('toggle', proxy, false)">
|
||||||
|
<el-icon><TurnOff /></el-icon>
|
||||||
<!-- Store actions -->
|
Disable
|
||||||
<div v-if="isStore" class="card-actions">
|
</PopoverMenuItem>
|
||||||
<button
|
<PopoverMenuItem @click="$emit('edit', proxy)">
|
||||||
class="action-btn edit-btn"
|
<el-icon><Edit /></el-icon>
|
||||||
@click.stop="$emit('edit', proxy)"
|
Edit
|
||||||
>
|
</PopoverMenuItem>
|
||||||
<svg
|
<PopoverMenuItem danger @click="$emit('delete', proxy)">
|
||||||
viewBox="0 0 16 16"
|
<el-icon><Delete /></el-icon>
|
||||||
fill="none"
|
Delete
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</PopoverMenuItem>
|
||||||
>
|
</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>
|
||||||
@@ -107,21 +52,40 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Warning } from '@element-plus/icons-vue'
|
import { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue'
|
||||||
import type { ProxyStatus } from '../types/proxy'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
|
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 = defineProps<Props>()
|
const props = withDefaults(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 isStore = computed(() => props.proxy.source === 'store')
|
const displaySource = computed(() => {
|
||||||
|
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) {
|
||||||
@@ -129,53 +93,43 @@ 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>
|
<style scoped lang="scss">
|
||||||
.proxy-card {
|
.proxy-card {
|
||||||
position: relative;
|
background: $color-bg-primary;
|
||||||
display: block;
|
border: 1px solid $color-border-lighter;
|
||||||
background: var(--el-bg-color);
|
border-radius: $radius-md;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
padding: 14px 20px;
|
||||||
border-radius: 12px;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all $transition-medium;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-card:hover {
|
&:hover {
|
||||||
border-color: var(--el-border-color);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
box-shadow:
|
border-color: $color-border;
|
||||||
0 4px 16px rgba(0, 0, 0, 0.06),
|
}
|
||||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-card.has-error {
|
&.has-error {
|
||||||
border-color: var(--el-color-danger-light-5);
|
border-color: rgba(245, 108, 108, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
padding: 18px 20px;
|
gap: $spacing-lg;
|
||||||
gap: 20px;
|
|
||||||
min-height: 76px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left Section */
|
|
||||||
.card-left {
|
.card-left {
|
||||||
display: flex;
|
@include flex-column;
|
||||||
flex-direction: column;
|
gap: $spacing-sm;
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -183,311 +137,68 @@ html.dark .proxy-card.has-error {
|
|||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-name {
|
.proxy-name {
|
||||||
font-size: 15px;
|
font-size: $font-size-lg;
|
||||||
font-weight: 600;
|
font-weight: $font-weight-semibold;
|
||||||
color: var(--el-text-color-primary);
|
color: $color-text-primary;
|
||||||
line-height: 1.3;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-tag {
|
.type-tag {
|
||||||
font-size: 10px;
|
font-size: $font-size-xs;
|
||||||
font-weight: 600;
|
font-weight: $font-weight-medium;
|
||||||
padding: 2px 6px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--el-fill-color);
|
background: $color-bg-muted;
|
||||||
color: var(--el-text-color-secondary);
|
color: $color-text-secondary;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-tag.type-tcp {
|
.card-address {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
color: #3b82f6;
|
font-size: $font-size-sm;
|
||||||
}
|
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: 16px;
|
gap: $spacing-sm;
|
||||||
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: 12px;
|
gap: $spacing-md;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-badge {
|
.source-label {
|
||||||
display: flex;
|
font-size: $font-size-xs;
|
||||||
align-items: center;
|
color: $color-text-light;
|
||||||
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%;
|
||||||
flex-shrink: 0;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.waiting {
|
@include mobile {
|
||||||
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: 14px;
|
gap: $spacing-sm;
|
||||||
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);
|
}
|
||||||
padding-top: 14px;
|
.card-address {
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
<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>
|
|
||||||
103
web/frpc/src/components/StatusPills.vue
Normal file
103
web/frpc/src/components/StatusPills.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<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>
|
||||||
141
web/frpc/src/components/StringListEditor.vue
Normal file
141
web/frpc/src/components/StringListEditor.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<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>
|
||||||
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal file
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
149
web/frpc/src/components/proxy-form/ProxyBackendSection.vue
Normal file
149
web/frpc/src/components/proxy-form/ProxyBackendSection.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<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>
|
||||||
51
web/frpc/src/components/proxy-form/ProxyBaseSection.vue
Normal file
51
web/frpc/src/components/proxy-form/ProxyBaseSection.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
50
web/frpc/src/components/proxy-form/ProxyFormLayout.vue
Normal file
50
web/frpc/src/components/proxy-form/ProxyFormLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<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>
|
||||||
52
web/frpc/src/components/proxy-form/ProxyHealthSection.vue
Normal file
52
web/frpc/src/components/proxy-form/ProxyHealthSection.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<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>
|
||||||
32
web/frpc/src/components/proxy-form/ProxyHttpSection.vue
Normal file
32
web/frpc/src/components/proxy-form/ProxyHttpSection.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
29
web/frpc/src/components/proxy-form/ProxyMetadataSection.vue
Normal file
29
web/frpc/src/components/proxy-form/ProxyMetadataSection.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
29
web/frpc/src/components/proxy-form/ProxyNatSection.vue
Normal file
29
web/frpc/src/components/proxy-form/ProxyNatSection.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
41
web/frpc/src/components/proxy-form/ProxyRemoteSection.vue
Normal file
41
web/frpc/src/components/proxy-form/ProxyRemoteSection.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<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>
|
||||||
39
web/frpc/src/components/proxy-form/ProxyTransportSection.vue
Normal file
39
web/frpc/src/components/proxy-form/ProxyTransportSection.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<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>
|
||||||
40
web/frpc/src/components/visitor-form/VisitorBaseSection.vue
Normal file
40
web/frpc/src/components/visitor-form/VisitorBaseSection.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<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>
|
||||||
33
web/frpc/src/components/visitor-form/VisitorFormLayout.vue
Normal file
33
web/frpc/src/components/visitor-form/VisitorFormLayout.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<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>
|
||||||
47
web/frpc/src/components/visitor-form/VisitorXtcpSection.vue
Normal file
47
web/frpc/src/components/visitor-form/VisitorXtcpSection.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
8
web/frpc/src/composables/useResponsive.ts
Normal file
8
web/frpc/src/composables/useResponsive.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useBreakpoints } from '@vueuse/core'
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })
|
||||||
|
|
||||||
|
export function useResponsive() {
|
||||||
|
const isMobile = breakpoints.smaller('desktop') // < 768px
|
||||||
|
return { isMobile }
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
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/custom.css'
|
import './assets/css/var.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,23 +1,26 @@
|
|||||||
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 ProxyEdit from '../views/ProxyEdit.vue'
|
import ProxyEdit from '../views/ProxyEdit.vue'
|
||||||
import VisitorEdit from '../views/VisitorEdit.vue'
|
import VisitorEdit from '../views/VisitorEdit.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: '/',
|
||||||
name: 'Overview',
|
redirect: '/proxies',
|
||||||
component: Overview,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/configure',
|
path: '/proxies',
|
||||||
name: 'ClientConfigure',
|
name: 'ProxyList',
|
||||||
component: ClientConfigure,
|
component: () => import('../views/ProxyList.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/proxies/detail/:name',
|
||||||
|
name: 'ProxyDetail',
|
||||||
|
component: () => import('../views/ProxyDetail.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/proxies/create',
|
path: '/proxies/create',
|
||||||
@@ -31,6 +34,16 @@ const router = createRouter({
|
|||||||
component: ProxyEdit,
|
component: ProxyEdit,
|
||||||
meta: { requiresStore: true },
|
meta: { requiresStore: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/visitors',
|
||||||
|
name: 'VisitorList',
|
||||||
|
component: () => import('../views/VisitorList.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/visitors/detail/:name',
|
||||||
|
name: 'VisitorDetail',
|
||||||
|
component: () => import('../views/VisitorDetail.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/visitors/create',
|
path: '/visitors/create',
|
||||||
name: 'VisitorCreate',
|
name: 'VisitorCreate',
|
||||||
@@ -43,27 +56,21 @@ 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 enabled = await isStoreEnabled()
|
const proxyStore = useProxyStore()
|
||||||
|
const enabled = await proxyStore.checkStoreEnabled()
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -71,7 +78,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: 'Overview' }
|
return { name: 'ProxyList' }
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
28
web/frpc/src/stores/client.ts
Normal file
28
web/frpc/src/stores/client.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 }
|
||||||
|
})
|
||||||
132
web/frpc/src/stores/proxy.ts
Normal file
132
web/frpc/src/stores/proxy.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
68
web/frpc/src/stores/visitor.ts
Normal file
68
web/frpc/src/stores/visitor.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
32
web/frpc/src/types/constants.ts
Normal file
32
web/frpc/src/types/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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]
|
||||||
5
web/frpc/src/types/index.ts
Normal file
5
web/frpc/src/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './constants'
|
||||||
|
export * from './proxy-status'
|
||||||
|
export * from './proxy-store'
|
||||||
|
export * from './proxy-form'
|
||||||
|
export * from './proxy-converters'
|
||||||
@@ -1,264 +1,7 @@
|
|||||||
// ========================================
|
import type { ProxyType, VisitorType } from './constants'
|
||||||
// RUNTIME STATUS TYPES (from /api/status)
|
import type { ProxyFormData, VisitorFormData } from './proxy-form'
|
||||||
// ========================================
|
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
|
||||||
@@ -359,11 +102,8 @@ 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) {
|
if (form.customDomains.length > 0) {
|
||||||
block.customDomains = form.customDomains
|
block.customDomains = form.customDomains.filter(Boolean)
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
}
|
||||||
if (form.subdomain) {
|
if (form.subdomain) {
|
||||||
block.subdomain = form.subdomain
|
block.subdomain = form.subdomain
|
||||||
@@ -371,11 +111,8 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.type === 'http') {
|
if (form.type === 'http') {
|
||||||
if (form.locations) {
|
if (form.locations.length > 0) {
|
||||||
block.locations = form.locations
|
block.locations = form.locations.filter(Boolean)
|
||||||
.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
|
||||||
@@ -409,11 +146,8 @@ 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) {
|
if (form.allowUsers.length > 0) {
|
||||||
block.allowUsers = form.allowUsers
|
block.allowUsers = form.allowUsers.filter(Boolean)
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,17 +378,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.join(', ')
|
|
||||||
} else if (c.customDomains) {
|
|
||||||
form.customDomains = c.customDomains
|
form.customDomains = c.customDomains
|
||||||
|
} else if (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.join(', ')
|
|
||||||
} else if (c.locations) {
|
|
||||||
form.locations = c.locations
|
form.locations = c.locations
|
||||||
|
} else if (c.locations) {
|
||||||
|
form.locations = [c.locations]
|
||||||
}
|
}
|
||||||
form.httpUser = c.httpUser || ''
|
form.httpUser = c.httpUser || ''
|
||||||
form.httpPassword = c.httpPassword || ''
|
form.httpPassword = c.httpPassword || ''
|
||||||
@@ -679,9 +413,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.join(', ')
|
|
||||||
} else if (c.allowUsers) {
|
|
||||||
form.allowUsers = c.allowUsers
|
form.allowUsers = c.allowUsers
|
||||||
|
} else if (c.allowUsers) {
|
||||||
|
form.allowUsers = [c.allowUsers]
|
||||||
}
|
}
|
||||||
|
|
||||||
// XTCP NAT traversal
|
// XTCP NAT traversal
|
||||||
167
web/frpc/src/types/proxy-form.ts
Normal file
167
web/frpc/src/types/proxy-form.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/frpc/src/types/proxy-status.ts
Normal file
13
web/frpc/src/types/proxy-status.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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[]>
|
||||||
30
web/frpc/src/types/proxy-store.ts
Normal file
30
web/frpc/src/types/proxy-store.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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[]
|
||||||
|
}
|
||||||
@@ -2,127 +2,68 @@
|
|||||||
<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">Configuration</h1>
|
<h1 class="page-title">Config</h1>
|
||||||
<p class="page-subtitle">
|
|
||||||
Edit and manage your frpc configuration file
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<div class="editor-header">
|
||||||
<el-col :xs="24" :lg="16">
|
<div class="header-left">
|
||||||
<el-card class="editor-card" shadow="hover">
|
<a
|
||||||
<template #header>
|
href="https://github.com/fatedier/frp#configuration-files"
|
||||||
<div class="card-header">
|
target="_blank"
|
||||||
<div class="header-left">
|
class="docs-link"
|
||||||
<span class="card-title">Configuration Editor</span>
|
>
|
||||||
<el-tag size="small" type="success">TOML</el-tag>
|
<el-icon><Link /></el-icon>
|
||||||
</div>
|
Documentation
|
||||||
<div class="header-actions">
|
</a>
|
||||||
<el-tooltip content="Refresh" placement="top">
|
</div>
|
||||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
<div class="header-actions">
|
||||||
</el-tooltip>
|
<ActionButton @click="handleUpload">Update & Reload</ActionButton>
|
||||||
<el-button type="primary" :icon="Upload" @click="handleUpload">
|
</div>
|
||||||
Update & Reload
|
</div>
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
<el-input
|
<el-input
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 20, maxRows: 40 }"
|
:autosize="false"
|
||||||
v-model="configContent"
|
v-model="configContent"
|
||||||
placeholder="# frpc configuration file content...
|
placeholder="# frpc configuration file content...
|
||||||
|
|
||||||
[common]
|
serverAddr = "127.0.0.1"
|
||||||
server_addr = 127.0.0.1
|
serverPort = 7000"
|
||||||
server_port = 7000"
|
class="code-editor"
|
||||||
class="code-editor"
|
></el-input>
|
||||||
></el-input>
|
</div>
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
|
|
||||||
<el-col :xs="24" :lg="8">
|
<ConfirmDialog
|
||||||
<el-card class="help-card" shadow="hover">
|
v-model="confirmVisible"
|
||||||
<template #header>
|
title="Confirm Update"
|
||||||
<div class="card-header">
|
message="This operation will update your frpc configuration and reload it. Do you want to continue?"
|
||||||
<span class="card-title">Quick Reference</span>
|
confirm-text="Update"
|
||||||
</div>
|
:loading="uploading"
|
||||||
</template>
|
:is-mobile="isMobile"
|
||||||
<div class="help-content">
|
@confirm="doUpload"
|
||||||
<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
|
|
||||||
href="https://github.com/fatedier/frp#configuration-files"
|
|
||||||
target="_blank"
|
|
||||||
class="docs-link"
|
|
||||||
>
|
|
||||||
<el-icon><Link /></el-icon>
|
|
||||||
View Full Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
|
import { Link } from '@element-plus/icons-vue'
|
||||||
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
import { useClientStore } from '../stores/client'
|
||||||
|
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 {
|
||||||
const text = await getConfig()
|
await clientStore.fetchConfig()
|
||||||
configContent.value = text
|
configContent.value = clientStore.config
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
@@ -132,256 +73,116 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = () => {
|
const confirmVisible = ref(false)
|
||||||
ElMessageBox.confirm(
|
const uploading = ref(false)
|
||||||
'This operation will update your frpc configuration and reload it. Do you want to continue?',
|
|
||||||
'Confirm Update',
|
|
||||||
{
|
|
||||||
confirmButtonText: 'Update',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
type: 'warning',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(async () => {
|
|
||||||
if (!configContent.value.trim()) {
|
|
||||||
ElMessage({
|
|
||||||
message: 'Configuration content cannot be empty!',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const handleUpload = () => {
|
||||||
await putConfig(configContent.value)
|
confirmVisible.value = true
|
||||||
await reloadConfig()
|
}
|
||||||
ElMessage({
|
|
||||||
type: 'success',
|
const doUpload = async () => {
|
||||||
message: 'Configuration updated and reloaded successfully',
|
if (!configContent.value.trim()) {
|
||||||
})
|
ElMessage.warning('Configuration content cannot be empty!')
|
||||||
} catch (err: any) {
|
return
|
||||||
ElMessage({
|
}
|
||||||
showClose: true,
|
|
||||||
message: 'Update failed: ' + err.message,
|
uploading.value = true
|
||||||
type: 'error',
|
try {
|
||||||
})
|
await clientStore.saveConfig(configContent.value)
|
||||||
}
|
await clientStore.reload()
|
||||||
})
|
ElMessage.success('Configuration updated and reloaded successfully')
|
||||||
.catch(() => {
|
confirmVisible.value = false
|
||||||
// cancelled
|
} catch (err: any) {
|
||||||
})
|
ElMessage.error('Update failed: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.configure-page {
|
.configure-page {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
overflow: hidden;
|
||||||
gap: 24px;
|
padding: $spacing-xl 40px;
|
||||||
|
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 {
|
||||||
display: flex;
|
@include flex-column;
|
||||||
flex-direction: column;
|
gap: $spacing-sm;
|
||||||
gap: 8px;
|
margin-bottom: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
.editor-header {
|
||||||
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: 12px;
|
gap: $spacing-sm;
|
||||||
}
|
|
||||||
|
|
||||||
.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: 8px;
|
gap: $spacing-xs;
|
||||||
color: var(--el-color-primary);
|
color: $color-text-muted;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: $font-size-sm;
|
||||||
font-weight: 500;
|
transition: color $transition-fast;
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--el-color-primary-light-9);
|
&:hover {
|
||||||
border-radius: 8px;
|
color: $color-text-primary;
|
||||||
transition: all 0.2s;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-link:hover {
|
.code-editor {
|
||||||
background: var(--el-color-primary-light-8);
|
height: 100%;
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@include mobile {
|
||||||
.card-header {
|
.configure-page {
|
||||||
flex-direction: column;
|
padding: $spacing-xl $spacing-lg;
|
||||||
align-items: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -392,10 +193,4 @@ html.dark .code-example {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.help-card {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
303
web/frpc/src/views/ProxyDetail.vue
Normal file
303
web/frpc/src/views/ProxyDetail.vue
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<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
440
web/frpc/src/views/ProxyList.vue
Normal file
440
web/frpc/src/views/ProxyList.vue
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<template>
|
||||||
|
<div class="proxies-page">
|
||||||
|
<!-- Fixed top area -->
|
||||||
|
<div class="page-top">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">Proxies</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<div class="tab-buttons">
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'status' }" @click="switchTab('status')">Status</button>
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'store' }" @click="switchTab('store')">Store</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton v-if="activeTab === 'store' && proxyStore.storeEnabled" size="small" @click="handleCreate">
|
||||||
|
+ New Proxy
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Tab Filters -->
|
||||||
|
<template v-if="activeTab === 'status'">
|
||||||
|
<StatusPills v-if="!isMobile" :items="proxyStore.proxies" v-model="statusFilter" />
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<FilterDropdown v-model="sourceFilter" label="Source" :options="sourceOptions" :min-width="140" :is-mobile="isMobile" />
|
||||||
|
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" :is-mobile="isMobile" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Store Tab Filters -->
|
||||||
|
<template v-if="activeTab === 'store' && proxyStore.storeEnabled">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="storeSearch" placeholder="Search..." clearable class="search-input">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<FilterDropdown v-model="storeTypeFilter" label="Type" :options="storeTypeOptions" :min-width="140" :is-mobile="isMobile" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable list area -->
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- Status Tab List -->
|
||||||
|
<div v-if="activeTab === 'status'" v-loading="proxyStore.loading">
|
||||||
|
<div v-if="filteredStatus.length > 0" class="proxy-list">
|
||||||
|
<ProxyCard
|
||||||
|
v-for="p in filteredStatus"
|
||||||
|
:key="p.name"
|
||||||
|
:proxy="p"
|
||||||
|
showSource
|
||||||
|
@click="goToDetail(p.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!proxyStore.loading" class="empty-state">
|
||||||
|
<p class="empty-text">No proxies found</p>
|
||||||
|
<p class="empty-hint">Proxies will appear here once configured and connected.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Store Tab List -->
|
||||||
|
<div v-if="activeTab === 'store'" v-loading="proxyStore.storeLoading">
|
||||||
|
<div v-if="!proxyStore.storeEnabled" class="store-disabled">
|
||||||
|
<p>Store is not enabled. Add the following to your frpc configuration:</p>
|
||||||
|
<pre class="config-hint">[store]
|
||||||
|
path = "./frpc_store.json"</pre>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="filteredStoreProxies.length > 0" class="proxy-list">
|
||||||
|
<ProxyCard
|
||||||
|
v-for="p in filteredStoreProxies"
|
||||||
|
:key="p.name"
|
||||||
|
:proxy="proxyStore.storeProxyWithStatus(p)"
|
||||||
|
showActions
|
||||||
|
@click="goToDetail(p.name)"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@toggle="handleToggleProxy"
|
||||||
|
@delete="handleDeleteProxy(p.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p class="empty-text">No store proxies</p>
|
||||||
|
<p class="empty-hint">Click "New Proxy" to create one.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model="deleteDialog.visible"
|
||||||
|
title="Delete Proxy"
|
||||||
|
:message="deleteDialog.message"
|
||||||
|
confirm-text="Delete"
|
||||||
|
danger
|
||||||
|
:loading="deleteDialog.loading"
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
@confirm="doDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
|
import StatusPills from '../components/StatusPills.vue'
|
||||||
|
import FilterDropdown from '@shared/components/FilterDropdown.vue'
|
||||||
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
|
import { useProxyStore } from '../stores/proxy'
|
||||||
|
import { useResponsive } from '../composables/useResponsive'
|
||||||
|
import type { ProxyStatus } from '../types'
|
||||||
|
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const proxyStore = useProxyStore()
|
||||||
|
|
||||||
|
// Tab
|
||||||
|
const activeTab = computed(() => {
|
||||||
|
const tab = route.query.tab as string
|
||||||
|
return tab === 'store' ? 'store' : 'status'
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchTab = (tab: string) => {
|
||||||
|
router.replace({ query: tab === 'status' ? {} : { tab } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters (local UI state)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
const sourceFilter = ref('')
|
||||||
|
const searchText = ref('')
|
||||||
|
const storeSearch = ref('')
|
||||||
|
const storeTypeFilter = ref('')
|
||||||
|
|
||||||
|
// Delete dialog
|
||||||
|
const deleteDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
title: 'Delete Proxy',
|
||||||
|
message: '',
|
||||||
|
loading: false,
|
||||||
|
name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Source handling
|
||||||
|
const displaySource = (proxy: ProxyStatus): string => {
|
||||||
|
return proxy.source === 'store' ? 'store' : 'config'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
const sourceOptions = computed(() => {
|
||||||
|
const sources = new Set<string>()
|
||||||
|
sources.add('config')
|
||||||
|
sources.add('store')
|
||||||
|
proxyStore.proxies.forEach((p) => {
|
||||||
|
sources.add(displaySource(p))
|
||||||
|
})
|
||||||
|
return Array.from(sources)
|
||||||
|
.sort()
|
||||||
|
.map((s) => ({ label: s, value: s }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const PROXY_TYPE_ORDER = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp']
|
||||||
|
|
||||||
|
const sortByTypeOrder = (types: string[]) => {
|
||||||
|
return types.sort((a, b) => {
|
||||||
|
const ia = PROXY_TYPE_ORDER.indexOf(a)
|
||||||
|
const ib = PROXY_TYPE_ORDER.indexOf(b)
|
||||||
|
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
const types = new Set<string>()
|
||||||
|
proxyStore.proxies.forEach((p) => types.add(p.type))
|
||||||
|
return sortByTypeOrder(Array.from(types))
|
||||||
|
.map((t) => ({ label: t.toUpperCase(), value: t }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeTypeOptions = computed(() => {
|
||||||
|
const types = new Set<string>()
|
||||||
|
proxyStore.storeProxies.forEach((p) => types.add(p.type))
|
||||||
|
return sortByTypeOrder(Array.from(types))
|
||||||
|
.map((t) => ({ label: t.toUpperCase(), value: t }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtered computeds — Status tab uses proxyStore.proxies (runtime only)
|
||||||
|
const filteredStatus = computed(() => {
|
||||||
|
let result = proxyStore.proxies as ProxyStatus[]
|
||||||
|
|
||||||
|
if (statusFilter.value) {
|
||||||
|
result = result.filter((p) => p.status === statusFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter.value) {
|
||||||
|
result = result.filter((p) => p.type === typeFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFilter.value) {
|
||||||
|
result = result.filter((p) => displaySource(p) === sourceFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText.value) {
|
||||||
|
const search = searchText.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(search) ||
|
||||||
|
p.type.toLowerCase().includes(search) ||
|
||||||
|
p.local_addr.toLowerCase().includes(search) ||
|
||||||
|
p.remote_addr.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredStoreProxies = computed(() => {
|
||||||
|
let list = proxyStore.storeProxies
|
||||||
|
|
||||||
|
if (storeTypeFilter.value) {
|
||||||
|
list = list.filter((p) => p.type === storeTypeFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storeSearch.value) {
|
||||||
|
const q = storeSearch.value.toLowerCase()
|
||||||
|
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
|
const refreshData = () => {
|
||||||
|
proxyStore.fetchStatus().catch((err: any) => {
|
||||||
|
ElMessage.error('Failed to get status: ' + err.message)
|
||||||
|
})
|
||||||
|
proxyStore.fetchStoreProxies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const goToDetail = (name: string) => {
|
||||||
|
router.push('/proxies/detail/' + encodeURIComponent(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push('/proxies/create')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (proxy: ProxyStatus) => {
|
||||||
|
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleProxy = async (proxy: ProxyStatus, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await proxyStore.toggleProxy(proxy.name, enabled)
|
||||||
|
ElMessage.success(enabled ? 'Proxy enabled' : 'Proxy disabled')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteProxy = (name: string) => {
|
||||||
|
deleteDialog.name = name
|
||||||
|
deleteDialog.message = `Are you sure you want to delete "${name}"? This action cannot be undone.`
|
||||||
|
deleteDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
deleteDialog.loading = true
|
||||||
|
try {
|
||||||
|
await proxyStore.deleteProxy(deleteDialog.name)
|
||||||
|
ElMessage.success('Proxy deleted')
|
||||||
|
deleteDialog.visible = false
|
||||||
|
proxyStore.fetchStatus()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||||
|
} finally {
|
||||||
|
deleteDialog.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.proxies-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-top {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: $spacing-xl 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 40px $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid $color-border-lighter;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: $spacing-sm $spacing-xl;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover { color: $color-text-primary; }
|
||||||
|
&.active {
|
||||||
|
color: $color-text-primary;
|
||||||
|
border-bottom-color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
padding-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
:deep(.search-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-disabled {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-hint {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.page-top {
|
||||||
|
padding: $spacing-lg $spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 0 $spacing-lg $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
:deep(.search-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
206
web/frpc/src/views/VisitorDetail.vue
Normal file
206
web/frpc/src/views/VisitorDetail.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div class="visitor-detail-page">
|
||||||
|
<!-- Fixed Header -->
|
||||||
|
<div class="detail-top">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<router-link to="/visitors" class="breadcrumb-link">Visitors</router-link>
|
||||||
|
<span class="breadcrumb-sep">›</span>
|
||||||
|
<span class="breadcrumb-current">{{ visitorName }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<template v-if="visitor">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="detail-title">{{ visitor.name }}</h2>
|
||||||
|
<p class="header-subtitle">Type: {{ visitor.type.toUpperCase() }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isStore" class="header-actions">
|
||||||
|
<ActionButton variant="outline" size="small" @click="handleEdit">
|
||||||
|
Edit
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notFound" class="not-found">
|
||||||
|
<p class="empty-text">Visitor not found</p>
|
||||||
|
<p class="empty-hint">The visitor "{{ visitorName }}" does not exist.</p>
|
||||||
|
<ActionButton variant="outline" @click="router.push('/visitors')">
|
||||||
|
Back to Visitors
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="visitor" v-loading="loading" class="detail-content">
|
||||||
|
<VisitorFormLayout
|
||||||
|
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 ActionButton from '@shared/components/ActionButton.vue'
|
||||||
|
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
|
||||||
|
import { getVisitorConfig, getStoreVisitor } from '../api/frpc'
|
||||||
|
import type { VisitorDefinition, VisitorFormData } from '../types'
|
||||||
|
import { storeVisitorToForm } from '../types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const visitorName = route.params.name as string
|
||||||
|
const visitor = ref<VisitorDefinition | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const notFound = ref(false)
|
||||||
|
const isStore = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const config = await getVisitorConfig(visitorName)
|
||||||
|
visitor.value = config
|
||||||
|
|
||||||
|
// Check if visitor is from the store (for Edit/Delete buttons)
|
||||||
|
try {
|
||||||
|
await getStoreVisitor(visitorName)
|
||||||
|
isStore.value = true
|
||||||
|
} catch {
|
||||||
|
// Not a store visitor — Edit/Delete not available
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.status === 404 || err?.response?.status === 404) {
|
||||||
|
notFound.value = true
|
||||||
|
} else {
|
||||||
|
notFound.value = true
|
||||||
|
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = computed<VisitorFormData | null>(() => {
|
||||||
|
if (!visitor.value) return null
|
||||||
|
return storeVisitorToForm(visitor.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push('/visitors/' + encodeURIComponent(visitorName) + '/edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.visitor-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="visitor-edit-page">
|
<div class="visitor-edit-page">
|
||||||
<!-- Breadcrumb -->
|
<div class="edit-header">
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<a class="breadcrumb-link" @click="goBack">
|
<router-link to="/visitors" class="breadcrumb-item">Visitors</router-link>
|
||||||
<el-icon><ArrowLeft /></el-icon>
|
<span class="breadcrumb-separator">›</span>
|
||||||
</a>
|
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'New Visitor' }}</span>
|
||||||
<router-link to="/" class="breadcrumb-item">Overview</router-link>
|
</nav>
|
||||||
<span class="breadcrumb-separator">/</span>
|
<div class="header-actions">
|
||||||
<span class="breadcrumb-current">{{
|
<ActionButton variant="outline" size="small" @click="goBack">Cancel</ActionButton>
|
||||||
isEditing ? 'Edit Visitor' : 'Create Visitor'
|
<ActionButton size="small" :loading="saving" @click="handleSave">
|
||||||
}}</span>
|
{{ isEditing ? 'Update' : 'Create' }}
|
||||||
</nav>
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-loading="pageLoading" class="edit-content">
|
<div v-loading="pageLoading" class="edit-content">
|
||||||
<el-form
|
<el-form
|
||||||
@@ -20,254 +22,52 @@
|
|||||||
label-position="top"
|
label-position="top"
|
||||||
@submit.prevent
|
@submit.prevent
|
||||||
>
|
>
|
||||||
<!-- Header Card -->
|
<VisitorFormLayout v-model="form" :editing="isEditing" />
|
||||||
<div class="form-card header-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="field-row three-col">
|
|
||||||
<el-form-item label="Name" prop="name" class="field-grow">
|
|
||||||
<el-input
|
|
||||||
v-model="form.name"
|
|
||||||
:disabled="isEditing"
|
|
||||||
placeholder="my-visitor"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Type" prop="type">
|
|
||||||
<el-select
|
|
||||||
v-model="form.type"
|
|
||||||
:disabled="isEditing"
|
|
||||||
:fit-input-width="false"
|
|
||||||
popper-class="visitor-type-dropdown"
|
|
||||||
class="type-select"
|
|
||||||
>
|
|
||||||
<el-option value="stcp" label="STCP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-stcp">STCP</span>
|
|
||||||
<span class="type-desc">Secure TCP Visitor</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
<el-option value="sudp" label="SUDP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-sudp">SUDP</span>
|
|
||||||
<span class="type-desc">Secure UDP Visitor</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
<el-option value="xtcp" label="XTCP">
|
|
||||||
<div class="type-option">
|
|
||||||
<span class="type-tag-inline type-xtcp">XTCP</span>
|
|
||||||
<span class="type-desc">P2P (NAT traversal)</span>
|
|
||||||
</div>
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Enabled">
|
|
||||||
<el-switch v-model="form.enabled" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection -->
|
|
||||||
<div class="form-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Connection</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Server Name" prop="serverName">
|
|
||||||
<el-input
|
|
||||||
v-model="form.serverName"
|
|
||||||
placeholder="Name of the proxy to visit"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Server User">
|
|
||||||
<el-input
|
|
||||||
v-model="form.serverUser"
|
|
||||||
placeholder="Leave empty for same user"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="Secret Key">
|
|
||||||
<el-input
|
|
||||||
v-model="form.secretKey"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="Shared secret"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Bind Address">
|
|
||||||
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Bind Port" prop="bindPort">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.bindPort"
|
|
||||||
:min="bindPortMin"
|
|
||||||
:max="65535"
|
|
||||||
controls-position="right"
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transport Options (collapsible) -->
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div
|
|
||||||
class="card-header clickable"
|
|
||||||
@click="transportExpanded = !transportExpanded"
|
|
||||||
>
|
|
||||||
<h3 class="card-title">Transport Options</h3>
|
|
||||||
<el-icon
|
|
||||||
class="collapse-icon"
|
|
||||||
:class="{ expanded: transportExpanded }"
|
|
||||||
><ArrowDown
|
|
||||||
/></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="transportExpanded" class="card-body">
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Use Encryption">
|
|
||||||
<el-switch v-model="form.useEncryption" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Use Compression">
|
|
||||||
<el-switch v-model="form.useCompression" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- XTCP Options (collapsible, xtcp only) -->
|
|
||||||
<template v-if="form.type === 'xtcp'">
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div
|
|
||||||
class="card-header clickable"
|
|
||||||
@click="xtcpExpanded = !xtcpExpanded"
|
|
||||||
>
|
|
||||||
<h3 class="card-title">XTCP Options</h3>
|
|
||||||
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"
|
|
||||||
><ArrowDown
|
|
||||||
/></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="xtcpExpanded" class="card-body">
|
|
||||||
<el-form-item label="Protocol">
|
|
||||||
<el-select v-model="form.protocol" class="full-width">
|
|
||||||
<el-option value="quic" label="QUIC" />
|
|
||||||
<el-option value="kcp" label="KCP" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Keep Tunnel Open">
|
|
||||||
<el-switch v-model="form.keepTunnelOpen" />
|
|
||||||
</el-form-item>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Max Retries per Hour">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.maxRetriesAnHour"
|
|
||||||
:min="0"
|
|
||||||
controls-position="right"
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Min Retry Interval (s)">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.minRetryInterval"
|
|
||||||
:min="0"
|
|
||||||
controls-position="right"
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
<div class="field-row two-col">
|
|
||||||
<el-form-item label="Fallback To">
|
|
||||||
<el-input
|
|
||||||
v-model="form.fallbackTo"
|
|
||||||
placeholder="Fallback visitor name"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Fallback Timeout (ms)">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.fallbackTimeoutMs"
|
|
||||||
:min="0"
|
|
||||||
controls-position="right"
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NAT Traversal (collapsible, xtcp only) -->
|
|
||||||
<div class="form-card collapsible-card">
|
|
||||||
<div
|
|
||||||
class="card-header clickable"
|
|
||||||
@click="natExpanded = !natExpanded"
|
|
||||||
>
|
|
||||||
<h3 class="card-title">NAT Traversal</h3>
|
|
||||||
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"
|
|
||||||
><ArrowDown
|
|
||||||
/></el-icon>
|
|
||||||
</div>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="natExpanded" class="card-body">
|
|
||||||
<el-form-item label="Disable Assisted Addresses">
|
|
||||||
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
|
|
||||||
<div class="form-tip">
|
|
||||||
Only use STUN-discovered public addresses
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky Footer -->
|
<ConfirmDialog
|
||||||
<div class="sticky-footer">
|
v-model="leaveDialogVisible"
|
||||||
<div class="footer-content">
|
title="Unsaved Changes"
|
||||||
<el-button @click="goBack">Cancel</el-button>
|
message="You have unsaved changes. Are you sure you want to leave?"
|
||||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
:is-mobile="isMobile"
|
||||||
{{ isEditing ? 'Update' : 'Create' }}
|
@confirm="handleLeaveConfirm"
|
||||||
</el-button>
|
@cancel="handleLeaveCancel"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
|
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
|
||||||
|
import { useResponsive } from '../composables/useResponsive'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
type VisitorFormData,
|
type VisitorFormData,
|
||||||
createDefaultVisitorForm,
|
createDefaultVisitorForm,
|
||||||
formToStoreVisitor,
|
formToStoreVisitor,
|
||||||
storeVisitorToForm,
|
storeVisitorToForm,
|
||||||
} from '../types/proxy'
|
} from '../types'
|
||||||
import {
|
import { getStoreVisitor } from '../api/frpc'
|
||||||
getStoreVisitor,
|
import { useVisitorStore } from '../stores/visitor'
|
||||||
createStoreVisitor,
|
|
||||||
updateStoreVisitor,
|
|
||||||
} from '../api/frpc'
|
|
||||||
|
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const visitorStore = useVisitorStore()
|
||||||
|
|
||||||
const isEditing = computed(() => !!route.params.name)
|
const isEditing = computed(() => !!route.params.name)
|
||||||
const pageLoading = ref(false)
|
const pageLoading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const form = ref<VisitorFormData>(createDefaultVisitorForm())
|
const form = ref<VisitorFormData>(createDefaultVisitorForm())
|
||||||
|
const dirty = ref(false)
|
||||||
const transportExpanded = ref(false)
|
const formSaved = ref(false)
|
||||||
const xtcpExpanded = ref(false)
|
const trackChanges = ref(false)
|
||||||
const natExpanded = ref(false)
|
|
||||||
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
|
||||||
|
|
||||||
const formRules: FormRules = {
|
const formRules: FormRules = {
|
||||||
name: [
|
name: [
|
||||||
@@ -310,22 +110,60 @@ const formRules: FormRules = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.push('/')
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.value,
|
||||||
|
() => {
|
||||||
|
if (trackChanges.value) {
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const leaveDialogVisible = ref(false)
|
||||||
|
const leaveResolve = ref<((value: boolean) => void) | null>(null)
|
||||||
|
|
||||||
|
onBeforeRouteLeave(async () => {
|
||||||
|
if (dirty.value && !formSaved.value) {
|
||||||
|
leaveDialogVisible.value = true
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
leaveResolve.value = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLeaveConfirm = () => {
|
||||||
|
leaveDialogVisible.value = false
|
||||||
|
leaveResolve.value?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeaveCancel = () => {
|
||||||
|
leaveDialogVisible.value = false
|
||||||
|
leaveResolve.value?.(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadVisitor = async () => {
|
const loadVisitor = async () => {
|
||||||
const name = route.params.name as string
|
const name = route.params.name as string
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
|
trackChanges.value = false
|
||||||
|
dirty.value = false
|
||||||
pageLoading.value = true
|
pageLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getStoreVisitor(name)
|
const res = await getStoreVisitor(name)
|
||||||
form.value = storeVisitorToForm(res)
|
form.value = storeVisitorToForm(res)
|
||||||
|
await nextTick()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||||
router.push('/')
|
router.push('/visitors')
|
||||||
} finally {
|
} finally {
|
||||||
pageLoading.value = false
|
pageLoading.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
trackChanges.value = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,13 +181,14 @@ const handleSave = async () => {
|
|||||||
try {
|
try {
|
||||||
const data = formToStoreVisitor(form.value)
|
const data = formToStoreVisitor(form.value)
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await updateStoreVisitor(form.value.name, data)
|
await visitorStore.updateVisitor(form.value.name, data)
|
||||||
ElMessage.success('Visitor updated')
|
ElMessage.success('Visitor updated')
|
||||||
} else {
|
} else {
|
||||||
await createStoreVisitor(data)
|
await visitorStore.createVisitor(data)
|
||||||
ElMessage.success('Visitor created')
|
ElMessage.success('Visitor created')
|
||||||
}
|
}
|
||||||
router.push('/')
|
formSaved.value = true
|
||||||
|
router.push('/visitors')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -360,6 +199,8 @@ const handleSave = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
loadVisitor()
|
loadVisitor()
|
||||||
|
} else {
|
||||||
|
trackChanges.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,14 +212,48 @@ watch(
|
|||||||
loadVisitor()
|
loadVisitor()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
trackChanges.value = false
|
||||||
form.value = createDefaultVisitorForm()
|
form.value = createDefaultVisitorForm()
|
||||||
|
dirty.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
trackChanges.value = true
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.visitor-edit-page {
|
.visitor-edit-page {
|
||||||
padding-bottom: 80px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.edit-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 160px;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breadcrumb */
|
/* Breadcrumb */
|
||||||
@@ -387,20 +262,6 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-item {
|
.breadcrumb-item {
|
||||||
@@ -422,185 +283,13 @@ watch(
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form Cards */
|
@include mobile {
|
||||||
.form-card {
|
.edit-header {
|
||||||
background: var(--el-bg-color);
|
padding: 20px 16px;
|
||||||
border: 1px solid var(--header-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .form-card {
|
|
||||||
border-color: #3a3d5c;
|
|
||||||
background: #27293d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .card-header {
|
|
||||||
border-bottom-color: #3a3d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header.clickable:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-card .card-header {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-card .card-body {
|
|
||||||
border-top: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .collapsible-card .card-body {
|
|
||||||
border-top-color: #3a3d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon {
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon.expanded {
|
|
||||||
transform: rotate(-180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Field Rows */
|
|
||||||
.field-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row.two-col {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row.three-col {
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-grow {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-select {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag-inline {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag-inline.type-stcp,
|
|
||||||
.type-tag-inline.type-sudp,
|
|
||||||
.type-tag-inline.type-xtcp {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: #8b5cf6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sticky Footer */
|
|
||||||
.sticky-footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 99;
|
|
||||||
background: var(--header-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border-top: 1px solid var(--header-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 16px 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.field-row.two-col,
|
|
||||||
.field-row.three-col {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.edit-content {
|
||||||
width: 100%;
|
padding: 0 16px 160px;
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
padding: 12px 20px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
.visitor-type-dropdown {
|
|
||||||
min-width: 300px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visitor-type-dropdown .el-select-dropdown__item {
|
|
||||||
height: auto;
|
|
||||||
padding: 8px 16px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
373
web/frpc/src/views/VisitorList.vue
Normal file
373
web/frpc/src/views/VisitorList.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<div class="visitors-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">Visitors</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<div class="tab-buttons">
|
||||||
|
<button class="tab-btn active">Store</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton v-if="visitorStore.storeEnabled" size="small" @click="handleCreate">
|
||||||
|
+ New Visitor
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="visitorStore.loading">
|
||||||
|
<div v-if="!visitorStore.storeEnabled" class="store-disabled">
|
||||||
|
<p>Store is not enabled. Add the following to your frpc configuration:</p>
|
||||||
|
<pre class="config-hint">[store]
|
||||||
|
path = "./frpc_store.json"</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" :is-mobile="isMobile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredVisitors.length > 0" class="visitor-list">
|
||||||
|
<div v-for="v in filteredVisitors" :key="v.name" class="visitor-card" @click="goToDetail(v.name)">
|
||||||
|
<div class="card-left">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="visitor-name">{{ v.name }}</span>
|
||||||
|
<span class="type-tag">{{ v.type.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="getServerName(v)" class="card-meta">{{ getServerName(v) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-right">
|
||||||
|
<div @click.stop>
|
||||||
|
<PopoverMenu :width="120" placement="bottom-end">
|
||||||
|
<template #trigger>
|
||||||
|
<ActionButton variant="outline" size="small">
|
||||||
|
<el-icon><MoreFilled /></el-icon>
|
||||||
|
</ActionButton>
|
||||||
|
</template>
|
||||||
|
<PopoverMenuItem @click="handleEdit(v)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
Edit
|
||||||
|
</PopoverMenuItem>
|
||||||
|
<PopoverMenuItem danger @click="handleDelete(v.name)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
Delete
|
||||||
|
</PopoverMenuItem>
|
||||||
|
</PopoverMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p class="empty-text">No visitors found</p>
|
||||||
|
<p class="empty-hint">Click "New Visitor" to create one.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog v-model="deleteDialog.visible" title="Delete Visitor"
|
||||||
|
:message="deleteDialog.message" confirm-text="Delete" danger
|
||||||
|
:loading="deleteDialog.loading" :is-mobile="isMobile" @confirm="doDelete" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh, MoreFilled, Edit, Delete } from '@element-plus/icons-vue'
|
||||||
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
|
import FilterDropdown from '@shared/components/FilterDropdown.vue'
|
||||||
|
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||||
|
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||||
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
|
import { useVisitorStore } from '../stores/visitor'
|
||||||
|
import { useResponsive } from '../composables/useResponsive'
|
||||||
|
import type { VisitorDefinition } from '../types'
|
||||||
|
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
const router = useRouter()
|
||||||
|
const visitorStore = useVisitorStore()
|
||||||
|
|
||||||
|
const searchText = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
|
||||||
|
const deleteDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
message: '',
|
||||||
|
loading: false,
|
||||||
|
name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'STCP', value: 'stcp' },
|
||||||
|
{ label: 'SUDP', value: 'sudp' },
|
||||||
|
{ label: 'XTCP', value: 'xtcp' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredVisitors = computed(() => {
|
||||||
|
let list = visitorStore.storeVisitors
|
||||||
|
|
||||||
|
if (typeFilter.value) {
|
||||||
|
list = list.filter((v) => v.type === typeFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText.value) {
|
||||||
|
const q = searchText.value.toLowerCase()
|
||||||
|
list = list.filter((v) => v.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const getServerName = (v: VisitorDefinition): string => {
|
||||||
|
const block = (v as any)[v.type]
|
||||||
|
return block?.serverName || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
visitorStore.fetchStoreVisitors()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push('/visitors/create')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (v: VisitorDefinition) => {
|
||||||
|
router.push('/visitors/' + encodeURIComponent(v.name) + '/edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToDetail = (name: string) => {
|
||||||
|
router.push('/visitors/detail/' + encodeURIComponent(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (name: string) => {
|
||||||
|
deleteDialog.name = name
|
||||||
|
deleteDialog.message = `Are you sure you want to delete visitor "${name}"? This action cannot be undone.`
|
||||||
|
deleteDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
deleteDialog.loading = true
|
||||||
|
try {
|
||||||
|
await visitorStore.deleteVisitor(deleteDialog.name)
|
||||||
|
ElMessage.success('Visitor deleted')
|
||||||
|
deleteDialog.visible = false
|
||||||
|
fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||||
|
} finally {
|
||||||
|
deleteDialog.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.visitors-page {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $spacing-xl 40px;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid $color-border-lighter;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: $spacing-sm $spacing-xl;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $color-text-primary;
|
||||||
|
border-bottom-color: $color-text-primary;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
|
:deep(.search-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: $color-bg-primary;
|
||||||
|
border: 1px solid $color-border-lighter;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 14px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-medium;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border-color: $color-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-left {
|
||||||
|
@include flex-column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-name {
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $color-bg-muted;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.store-disabled {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-hint {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
background: $color-bg-hover;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.visitors-page {
|
||||||
|
padding: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
:deep(.search-input) {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user