Compare commits

..

1 Commits

Author SHA1 Message Date
fatedier
681fa87fae config: add enabled field for individual proxy and visitor 2025-11-06 11:47:20 +08:00
154 changed files with 7914 additions and 21091 deletions

View File

@@ -6,14 +6,6 @@ jobs:
resource_class: large
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
- run: make alltest

View File

@@ -19,15 +19,6 @@ jobs:
with:
go-version: '1.24'
cache: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
run: make install build
working-directory: web/frps
- name: Build web assets (frpc)
run: make install build
working-directory: web/frpc
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:

View File

@@ -16,21 +16,13 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: '1.24'
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
run: make install build
working-directory: web/frps
- name: Build web assets (frpc)
run: make install build
working-directory: web/frpc
- name: Make All
run: |
./package.sh
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --clean --release-notes=./Release.md

View File

@@ -39,7 +39,6 @@ linters:
- G404
- G501
- G115
- G204
severity: low
confidence: low
govet:

View File

@@ -2,22 +2,19 @@ export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on
LDFLAGS := -s -w
.PHONY: web frps-web frpc-web frps frpc
all: env fmt web build
all: env fmt build
build: frps frpc
env:
@go version
web: frps-web frpc-web
frps-web:
$(MAKE) -C web/frps build
frpc-web:
$(MAKE) -C web/frpc build
# compile assets into binary file
file:
rm -rf ./assets/frps/static/*
rm -rf ./assets/frpc/static/*
cp -rf ./web/frps/dist/* ./assets/frps/static
cp -rf ./web/frpc/dist/* ./assets/frpc/static
fmt:
go fmt ./...
@@ -28,7 +25,7 @@ fmt-more:
gci:
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
vet: web
vet:
go vet ./...
frps:
@@ -39,7 +36,7 @@ frpc:
test: gotest
gotest: web
gotest:
go test -v --cover ./assets/...
go test -v --cover ./cmd/...
go test -v --cover ./client/...

View File

@@ -13,33 +13,6 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
<br>
<b>The complete IDE crafted for professional Go developers</b>
</a>
</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>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -49,6 +22,47 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://go.warp.dev/frp" target="_blank">
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
<br>
<b>Warp, built for collaborating with AI Agents</b>
<br>
<sub>Available for macOS, Linux and Windows</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
<br>
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/daytonaio/daytona" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
<br>
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
</a>
</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-->
## What is frp?

View File

@@ -15,33 +15,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
<br>
<b>The complete IDE crafted for professional Go developers</b>
</a>
</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>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -51,6 +24,47 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://go.warp.dev/frp" target="_blank">
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
<br>
<b>Warp, built for collaborating with AI Agents</b>
<br>
<sub>Available for macOS, Linux and Windows</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
<br>
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/daytonaio/daytona" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
<br>
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
</a>
</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-->
## 为什么使用 frp
@@ -123,3 +137,9 @@ frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进
国内用户可以通过 [爱发电](https://afdian.com/a/fatedier) 赞助我们。
企业赞助者可以将贵公司的 Logo 以及链接放置在项目 README 文件中。
### 知识星球
如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何 frp 使用方面的帮助,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群:
![zsxq](/doc/pic/zsxq.jpg)

View File

@@ -1,8 +1,8 @@
## Features
* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities.
* Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load.
## Fixes
## Improvements
* Fixed UDP proxy protocol sending header on every packet instead of only the first packet of each session.
* **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals.

View File

@@ -41,7 +41,7 @@ func Load(path string) {
}
func Register(fileSystem fs.FS) {
subFs, err := fs.Sub(fileSystem, "dist")
subFs, err := fs.Sub(fileSystem, "static")
if err == nil {
content = subFs
}

View File

@@ -6,9 +6,9 @@ import (
"github.com/fatedier/frp/assets"
)
//go:embed dist
var EmbedFS embed.FS
//go:embed static/*
var content embed.FS
func init() {
assets.Register(EmbedFS)
assets.Register(content)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>frp client admin UI</title>
<script type="module" crossorigin src="./index-bLBhaJo8.js"></script>
<link rel="stylesheet" crossorigin href="./index-iuf46MlF.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

14
assets/frps/embed.go Normal file
View File

@@ -0,0 +1,14 @@
package frpc
import (
"embed"
"github.com/fatedier/frp/assets"
)
//go:embed static/*
var content embed.FS
func init() {
assets.Register(content)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<title>frps dashboard</title>
<script type="module" crossorigin src="./index-82-40HIG.js"></script>
<link rel="stylesheet" crossorigin href="./index-rzPDshRD.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -15,29 +15,44 @@
package client
import (
"cmp"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/api"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
type GeneralResponse struct {
Code int
Msg string
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
apiController := newAPIController(svr)
// Healthz endpoint without auth
helper.Router.HandleFunc("/healthz", healthz)
// API routes and static files with auth
helper.Router.HandleFunc("/healthz", svr.healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware)
subRouter.Use(httppkg.NewRequestLogger)
subRouter.HandleFunc("/api/reload", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/stop", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost)
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.PutConfig)).Methods(http.MethodPut)
subRouter.Use(helper.AuthMiddleware.Middleware)
// api, see admin_api.go
subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET")
subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST")
subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET")
subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET")
subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
@@ -47,28 +62,201 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
})
}
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}
func newAPIController(svr *Service) *api.Controller {
return api.NewController(api.ControllerParams{
GetProxyStatus: svr.getAllProxyStatus,
ServerAddr: svr.common.ServerAddr,
ConfigFilePath: svr.configFilePath,
UnsafeFeatures: svr.unsafeFeatures,
UpdateConfig: svr.UpdateAllConfigurer,
GracefulClose: svr.GracefulClose,
})
// GET /api/reload
func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
strictConfigMode := false
strictStr := r.URL.Query().Get("strictConfig")
if strictStr != "" {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
log.Infof("api request [/api/reload]")
defer func() {
log.Infof("api response [/api/reload], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.configFilePath, strictConfigMode)
if err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs); err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
if err := svr.UpdateAllConfigurer(proxyCfgs, visitorCfgs); err != nil {
res.Code = 500
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
log.Infof("success reload conf")
}
// getAllProxyStatus returns all proxy statuses.
func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {
// POST /api/stop
func (svr *Service) apiStop(w http.ResponseWriter, _ *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("api request [/api/stop]")
defer func() {
log.Infof("api response [/api/stop], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
go svr.GracefulClose(100 * time.Millisecond)
}
type StatusResp map[string][]ProxyStatusResp
type ProxyStatusResp struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err"`
LocalAddr string `json:"local_addr"`
Plugin string `json:"plugin"`
RemoteAddr string `json:"remote_addr"`
}
func NewProxyStatusResp(status *proxy.WorkingStatus, serverAddr string) ProxyStatusResp {
psr := ProxyStatusResp{
Name: status.Name,
Type: status.Type,
Status: status.Phase,
Err: status.Err,
}
baseCfg := status.Cfg.GetBaseConfig()
if baseCfg.LocalPort != 0 {
psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
}
psr.Plugin = baseCfg.Plugin.Type
if status.Err == "" {
psr.RemoteAddr = status.RemoteAddr
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
psr.RemoteAddr = serverAddr + psr.RemoteAddr
}
}
return psr
}
// GET /api/status
func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
var (
buf []byte
res StatusResp = make(map[string][]ProxyStatusResp)
)
log.Infof("http request [/api/status]")
defer func() {
log.Infof("http response [/api/status]")
buf, _ = json.Marshal(&res)
_, _ = w.Write(buf)
}()
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl == nil {
return nil
return
}
ps := ctl.pm.GetAllProxyStatus()
for _, status := range ps {
res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.common.ServerAddr))
}
for _, arrs := range res {
if len(arrs) <= 1 {
continue
}
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
return cmp.Compare(a.Name, b.Name)
})
}
}
// GET /api/config
func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http get request [/api/config]")
defer func() {
log.Infof("http get response [/api/config], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
if svr.configFilePath == "" {
res.Code = 400
res.Msg = "frpc has no config file path"
log.Warnf("%s", res.Msg)
return
}
content, err := os.ReadFile(svr.configFilePath)
if err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("load frpc config file error: %s", res.Msg)
return
}
res.Msg = string(content)
}
// PUT /api/config
func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http put request [/api/config]")
defer func() {
log.Infof("http put response [/api/config], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
// get new config content
body, err := io.ReadAll(r.Body)
if err != nil {
res.Code = 400
res.Msg = fmt.Sprintf("read request body error: %v", err)
log.Warnf("%s", res.Msg)
return
}
if len(body) == 0 {
res.Code = 400
res.Msg = "body can't be empty"
log.Warnf("%s", res.Msg)
return
}
if err := os.WriteFile(svr.configFilePath, body, 0o600); err != nil {
res.Code = 500
res.Msg = fmt.Sprintf("write content to frpc config file error: %v", err)
log.Warnf("%s", res.Msg)
return
}
return ctl.pm.GetAllProxyStatus()
}

View File

@@ -1,189 +0,0 @@
// Copyright 2025 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 api
import (
"cmp"
"fmt"
"net"
"net/http"
"os"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
)
// Controller handles HTTP API requests for frpc.
type Controller struct {
// getProxyStatus returns the current proxy status.
// Returns nil if the control connection is not established.
getProxyStatus func() []*proxy.WorkingStatus
// serverAddr is the frps server address for display.
serverAddr string
// configFilePath is the path to the configuration file.
configFilePath string
// unsafeFeatures is used for validation.
unsafeFeatures *security.UnsafeFeatures
// updateConfig updates proxy and visitor configurations.
updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
// gracefulClose gracefully stops the service.
gracefulClose func(d time.Duration)
}
// ControllerParams contains parameters for creating an APIController.
type ControllerParams struct {
GetProxyStatus func() []*proxy.WorkingStatus
ServerAddr string
ConfigFilePath string
UnsafeFeatures *security.UnsafeFeatures
UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
GracefulClose func(d time.Duration)
}
// NewController creates a new Controller.
func NewController(params ControllerParams) *Controller {
return &Controller{
getProxyStatus: params.GetProxyStatus,
serverAddr: params.ServerAddr,
configFilePath: params.ConfigFilePath,
unsafeFeatures: params.UnsafeFeatures,
updateConfig: params.UpdateConfig,
gracefulClose: params.GracefulClose,
}
}
// Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false
strictStr := ctx.Query("strictConfig")
if strictStr != "" {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
if err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
log.Infof("success reload conf")
return nil, nil
}
// Stop handles POST /api/stop
func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
go c.gracefulClose(100 * time.Millisecond)
return nil, nil
}
// Status handles GET /api/status
func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
res := make(StatusResp)
ps := c.getProxyStatus()
if ps == nil {
return res, nil
}
for _, status := range ps {
res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
}
for _, arrs := range res {
if len(arrs) <= 1 {
continue
}
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
return cmp.Compare(a.Name, b.Name)
})
}
return res, nil
}
// GetConfig handles GET /api/config
func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
if c.configFilePath == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "frpc has no config file path")
}
content, err := os.ReadFile(c.configFilePath)
if err != nil {
log.Warnf("load frpc config file error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
return string(content), nil
}
// PutConfig handles PUT /api/config
func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
}
if len(body) == 0 {
return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
}
if err := os.WriteFile(c.configFilePath, body, 0o600); err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("write content to frpc config file error: %v", err))
}
return nil, nil
}
// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
psr := ProxyStatusResp{
Name: status.Name,
Type: status.Type,
Status: status.Phase,
Err: status.Err,
}
baseCfg := status.Cfg.GetBaseConfig()
if baseCfg.LocalPort != 0 {
psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
}
psr.Plugin = baseCfg.Plugin.Type
if status.Err == "" {
psr.RemoteAddr = status.RemoteAddr
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
}
}
return psr
}

View File

@@ -1,29 +0,0 @@
// Copyright 2025 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 api
// StatusResp is the response for GET /api/status
type StatusResp map[string][]ProxyStatusResp
// ProxyStatusResp contains proxy status information
type ProxyStatusResp struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err"`
LocalAddr string `json:"local_addr"`
Plugin string `json:"plugin"`
RemoteAddr string `json:"remote_addr"`
}

View File

@@ -43,8 +43,8 @@ type SessionContext struct {
Conn net.Conn
// Indicates whether the connection is encrypted.
ConnEncrypted bool
// Auth runtime used for login, heartbeats, and encryption.
Auth *auth.ClientAuth
// Sets authentication based on selected method
AuthSetter auth.Setter
// Connector is used to create new connections, which could be real TCP connections or virtual streams.
Connector Connector
// Virtual net controller
@@ -91,7 +91,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.lastPong.Store(time.Now())
if sessionCtx.ConnEncrypted {
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, []byte(sessionCtx.Common.Auth.Token))
if err != nil {
return nil, err
}
@@ -100,9 +100,9 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
}
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel())
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
return ctl, nil
@@ -133,7 +133,7 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
m := &msg.NewWorkConn{
RunID: ctl.sessionCtx.RunID,
}
if err = ctl.sessionCtx.Auth.Setter.SetNewWorkConn(m); err != nil {
if err = ctl.sessionCtx.AuthSetter.SetNewWorkConn(m); err != nil {
xl.Warnf("error during NewWorkConn authentication: %v", err)
workConn.Close()
return
@@ -243,7 +243,7 @@ func (ctl *Control) heartbeatWorker() {
sendHeartBeat := func() (bool, error) {
xl.Debugf("send heartbeat to server")
pingMsg := &msg.Ping{}
if err := ctl.sessionCtx.Auth.Setter.SetPing(pingMsg); err != nil {
if err := ctl.sessionCtx.AuthSetter.SetPing(pingMsg); err != nil {
xl.Warnf("error during ping authentication: %v, skip sending ping message", err)
return false, err
}

View File

@@ -57,7 +57,6 @@ func NewProxy(
ctx context.Context,
pxyConf v1.ProxyConfigurer,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
) (pxy Proxy) {
@@ -70,7 +69,6 @@ func NewProxy(
baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(),
clientCfg: clientCfg,
encryptionKey: encryptionKey,
limiter: limiter,
msgTransporter: msgTransporter,
vnetController: vnetController,
@@ -88,7 +86,6 @@ func NewProxy(
type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
msgTransporter transport.MessageTransporter
vnetController *vnet.Controller
limiter *rate.Limiter
@@ -132,7 +129,7 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
return
}
}
pxy.HandleTCPWorkConnection(conn, m, pxy.encryptionKey)
pxy.HandleTCPWorkConnection(conn, m, []byte(pxy.clientCfg.Auth.Token))
}
// Common handler for tcp work connections.

View File

@@ -40,8 +40,7 @@ type Manager struct {
closed bool
mu sync.RWMutex
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
clientCfg *v1.ClientCommonConfig
ctx context.Context
}
@@ -49,7 +48,6 @@ type Manager struct {
func NewManager(
ctx context.Context,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
) *Manager {
@@ -58,7 +56,6 @@ func NewManager(
msgTransporter: msgTransporter,
vnetController: vnetController,
closed: false,
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
}
@@ -166,7 +163,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
for _, cfg := range proxyCfgs {
name := cfg.GetBaseConfig().Name
if _, ok := pm.proxies[name]; !ok {
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}

View File

@@ -92,7 +92,6 @@ func NewWrapper(
ctx context.Context,
cfg v1.ProxyConfigurer,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
eventHandler event.Handler,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
@@ -123,7 +122,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter, pw.vnetController)
return pw
}

View File

@@ -91,7 +91,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
})
}
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token))
if err != nil {
conn.Close()
xl.Errorf("create encryption stream error: %v", err)

View File

@@ -102,7 +102,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
})
}
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token))
if err != nil {
conn.Close()
xl.Errorf("create encryption stream error: %v", err)

View File

@@ -31,7 +31,6 @@ import (
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -65,8 +64,6 @@ type ServiceOptions struct {
ProxyCfgs []v1.ProxyConfigurer
VisitorCfgs []v1.VisitorConfigurer
UnsafeFeatures *security.UnsafeFeatures
// ConfigFilePath is the path to the configuration file used to initialize.
// If it is empty, it means that the configuration file is not used for initialization.
// It may be initialized using command line parameters or called directly.
@@ -111,8 +108,8 @@ type Service struct {
// Uniq id got from frps, it will be attached to loginMsg.
runID string
// Auth runtime and encryption materials
auth *auth.ClientAuth
// Sets authentication based on selected method
authSetter auth.Setter
// web server for admin UI and apis
webServer *httppkg.Server
@@ -125,8 +122,6 @@ type Service struct {
visitorCfgs []v1.VisitorConfigurer
clientSpec *msg.ClientSpec
unsafeFeatures *security.UnsafeFeatures
// The configuration file used to initialize this client, or an empty
// string if no configuration file was used.
configFilePath string
@@ -155,18 +150,17 @@ func NewService(options ServiceOptions) (*Service, error) {
webServer = ws
}
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
authSetter, err := auth.NewAuthSetter(options.Common.Auth)
if err != nil {
return nil, err
}
s := &Service{
ctx: context.Background(),
auth: authRuntime,
authSetter: authSetter,
webServer: webServer,
common: options.Common,
configFilePath: options.ConfigFilePath,
unsafeFeatures: options.UnsafeFeatures,
proxyCfgs: options.ProxyCfgs,
visitorCfgs: options.VisitorCfgs,
clientSpec: options.ClientSpec,
@@ -281,15 +275,11 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
return
}
hostname, _ := os.Hostname()
loginMsg := &msg.Login{
Arch: runtime.GOARCH,
Os: runtime.GOOS,
Hostname: hostname,
PoolCount: svr.common.Transport.PoolCount,
User: svr.common.User,
ClientID: svr.common.ClientID,
Version: version.Full(),
Timestamp: time.Now().Unix(),
RunID: svr.runID,
@@ -300,7 +290,7 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
}
// Add auth
if err = svr.auth.Setter.SetLogin(loginMsg); err != nil {
if err = svr.authSetter.SetLogin(loginMsg); err != nil {
return
}
@@ -354,7 +344,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
RunID: svr.runID,
Conn: conn,
ConnEncrypted: connEncrypted,
Auth: svr.auth,
AuthSetter: svr.authSetter,
Connector: connector,
VnetController: svr.vnetController,
}

View File

@@ -15,9 +15,9 @@
package main
import (
_ "github.com/fatedier/frp/assets/frpc"
"github.com/fatedier/frp/cmd/frpc/sub"
"github.com/fatedier/frp/pkg/util/system"
_ "github.com/fatedier/frp/web/frpc"
)
func main() {

View File

@@ -24,7 +24,6 @@ import (
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
)
var proxyTypes = []v1.ProxyType{
@@ -78,10 +77,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
os.Exit(1)
}
@@ -92,7 +88,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "")
err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -110,9 +106,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
os.Exit(1)
}
@@ -123,7 +117,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "")
err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "")
if err != nil {
fmt.Println(err)
os.Exit(1)

View File

@@ -21,7 +21,6 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -32,8 +31,7 @@ import (
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/featuregate"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
)
@@ -43,7 +41,6 @@ var (
cfgDir string
showVersion bool
strictConfigMode bool
allowUnsafe []string
)
func init() {
@@ -51,9 +48,6 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
}
var rootCmd = &cobra.Command{
@@ -65,17 +59,15 @@ var rootCmd = &cobra.Command{
return nil
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
// Note that it's only designed for testing. It's not guaranteed to be stable.
if cfgDir != "" {
_ = runMultipleClients(cfgDir, unsafeFeatures)
_ = runMultipleClients(cfgDir)
return nil
}
// Do not show command usage here.
err := runClient(cfgFile, unsafeFeatures)
err := runClient(cfgFile)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -84,7 +76,7 @@ var rootCmd = &cobra.Command{
},
}
func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures) error {
func runMultipleClients(cfgDir string) error {
var wg sync.WaitGroup
err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -94,7 +86,7 @@ func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures)
time.Sleep(time.Millisecond)
go func() {
defer wg.Done()
err := runClient(path, unsafeFeatures)
err := runClient(path)
if err != nil {
fmt.Printf("frpc service error for config file [%s]\n", path)
}
@@ -119,7 +111,7 @@ func handleTermSignal(svr *client.Service) {
svr.GracefulClose(500 * time.Millisecond)
}
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
func runClient(cfgFilePath string) error {
cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
if err != nil {
return err
@@ -135,22 +127,20 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
}
}
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath)
return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath)
}
func startService(
cfg *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
@@ -163,7 +153,6 @@ func startService(
Common: cfg,
ProxyCfgs: proxyCfgs,
VisitorCfgs: visitorCfgs,
UnsafeFeatures: unsafeFeatures,
ConfigFilePath: cfgFile,
})
if err != nil {

View File

@@ -22,7 +22,6 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
)
func init() {
@@ -43,8 +42,7 @@ var verifyCmd = &cobra.Command{
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

View File

@@ -15,9 +15,9 @@
package main
import (
_ "github.com/fatedier/frp/assets/frps"
_ "github.com/fatedier/frp/pkg/metrics"
"github.com/fatedier/frp/pkg/util/system"
_ "github.com/fatedier/frp/web/frps"
)
func main() {

View File

@@ -18,14 +18,12 @@ import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/server"
@@ -35,7 +33,6 @@ var (
cfgFile string
showVersion bool
strictConfigMode bool
allowUnsafe []string
serverCfg v1.ServerConfig
)
@@ -44,8 +41,6 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause errors")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ServerUnsafeFeatures, ", ")))
config.RegisterServerConfigFlags(rootCmd, &serverCfg)
}
@@ -82,9 +77,7 @@ var rootCmd = &cobra.Command{
svrCfg = &serverCfg
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
warning, err := validator.ValidateServerConfig(svrCfg)
warning, err := validation.ValidateServerConfig(svrCfg)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

View File

@@ -22,7 +22,6 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
)
func init() {
@@ -43,9 +42,7 @@ var verifyCmd = &cobra.Command{
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
warning, err := validator.ValidateServerConfig(svrCfg)
warning, err := validation.ValidateServerConfig(svrCfg)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

View File

@@ -1,7 +1,5 @@
# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.
# Optional unique identifier for this frpc instance.
clientID = "your_client_id"
# your proxy name will be changed to {user}.{proxy}
user = "your_name"

BIN
doc/pic/zsxq.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,17 +1,9 @@
FROM node:22 AS web-builder
WORKDIR /web/frpc
COPY web/frpc/ ./
RUN npm install
RUN npm run build
FROM golang:1.24 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
WORKDIR /building
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frpc -o bin/frpc ./cmd/frpc
RUN make frpc
FROM alpine:3

View File

@@ -1,17 +1,9 @@
FROM node:22 AS web-builder
WORKDIR /web/frps
COPY web/frps/ ./
RUN npm install
RUN npm run build
FROM golang:1.24 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
WORKDIR /building
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps
RUN make frps
FROM alpine:3

View File

@@ -15,7 +15,6 @@
package auth
import (
"context"
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -28,51 +27,14 @@ type Setter interface {
SetNewWorkConn(*msg.NewWorkConn) error
}
type ClientAuth struct {
Setter Setter
key []byte
}
func (a *ClientAuth) EncryptionKey() []byte {
return a.key
}
// BuildClientAuth resolves any dynamic auth values and returns a prepared auth runtime.
// Caller must run validation before calling this function.
func BuildClientAuth(cfg *v1.AuthClientConfig) (*ClientAuth, error) {
if cfg == nil {
return nil, fmt.Errorf("auth config is nil")
}
resolved := *cfg
if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {
token, err := resolved.TokenSource.Resolve(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
resolved.Token = token
}
setter, err := NewAuthSetter(resolved)
if err != nil {
return nil, err
}
return &ClientAuth{
Setter: setter,
key: []byte(resolved.Token),
}, nil
}
func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {
switch cfg.Method {
case v1.AuthMethodToken:
authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)
case v1.AuthMethodOIDC:
if cfg.OIDC.TokenSource != nil {
authProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource)
} else {
authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
if err != nil {
return nil, err
}
authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method)
@@ -86,35 +48,6 @@ type Verifier interface {
VerifyNewWorkConn(*msg.NewWorkConn) error
}
type ServerAuth struct {
Verifier Verifier
key []byte
}
func (a *ServerAuth) EncryptionKey() []byte {
return a.key
}
// BuildServerAuth resolves any dynamic auth values and returns a prepared auth runtime.
// Caller must run validation before calling this function.
func BuildServerAuth(cfg *v1.AuthServerConfig) (*ServerAuth, error) {
if cfg == nil {
return nil, fmt.Errorf("auth config is nil")
}
resolved := *cfg
if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {
token, err := resolved.TokenSource.Resolve(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
resolved.Token = token
}
return &ServerAuth{
Verifier: NewAuthVerifier(resolved),
key: []byte(resolved.Token),
}, nil
}
func NewAuthVerifier(cfg v1.AuthServerConfig) (authVerifier Verifier) {
switch cfg.Method {
case v1.AuthMethodToken:

View File

@@ -152,51 +152,6 @@ func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (e
return err
}
type OidcTokenSourceAuthProvider struct {
additionalAuthScopes []v1.AuthScope
valueSource *v1.ValueSource
}
func NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider {
return &OidcTokenSourceAuthProvider{
additionalAuthScopes: additionalAuthScopes,
valueSource: valueSource,
}
}
func (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) {
ctx := context.Background()
accessToken, err = auth.valueSource.Resolve(ctx)
if err != nil {
return "", fmt.Errorf("couldn't acquire OIDC token for login: %v", err)
}
return
}
func (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) {
loginMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
func (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) {
if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {
return nil
}
pingMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
func (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {
if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {
return nil
}
newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
type TokenVerifier interface {
Verify(context.Context, string) (*oidc.IDToken, error)
}

View File

@@ -167,7 +167,6 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
cmd.PersistentFlags().StringVar(&c.ClientID, "client-id", "", "unique identifier for this frpc instance")
cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token")
}

View File

@@ -15,6 +15,8 @@
package v1
import (
"context"
"fmt"
"os"
"github.com/samber/lo"
@@ -37,8 +39,6 @@ type ClientCommonConfig struct {
// clients. If this value is not "", proxy names will automatically be
// changed to "{user}.{proxy_name}".
User string `json:"user,omitempty"`
// ClientID uniquely identifies this frpc instance.
ClientID string `json:"clientID,omitempty"`
// ServerAddr specifies the address of the server to connect to. By
// default, this value is "0.0.0.0".
@@ -198,6 +198,17 @@ type AuthClientConfig struct {
func (c *AuthClientConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}
@@ -228,10 +239,6 @@ type AuthOIDCClientConfig struct {
// Supports http, https, socks5, and socks5h proxy protocols.
// If empty, no proxy is used for OIDC connections.
ProxyURL string `json:"proxyURL,omitempty"`
// TokenSource specifies a custom dynamic source for the authorization token.
// This is mutually exclusive with every other field of this structure.
TokenSource *ValueSource `json:"tokenSource,omitempty"`
}
type VirtualNetConfig struct {

View File

@@ -15,6 +15,8 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -36,9 +38,68 @@ func TestClientConfigComplete(t *testing.T) {
}
func TestAuthClientConfig_Complete(t *testing.T) {
require := require.New(t)
cfg := &AuthClientConfig{}
err := cfg.Complete()
require.NoError(err)
require.EqualValues("token", cfg.Method)
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "client-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthClientConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthClientConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
}

View File

@@ -15,6 +15,9 @@
package v1
import (
"context"
"fmt"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
@@ -135,6 +138,17 @@ type AuthServerConfig struct {
func (c *AuthServerConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}

View File

@@ -15,6 +15,8 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -33,9 +35,68 @@ func TestServerConfigComplete(t *testing.T) {
}
func TestAuthServerConfig_Complete(t *testing.T) {
require := require.New(t)
cfg := &AuthServerConfig{}
err := cfg.Complete()
require.NoError(err)
require.EqualValues("token", cfg.Method)
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "file-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthServerConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthServerConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
}

View File

@@ -23,109 +23,55 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/featuregate"
)
func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
var (
warnings Warning
errs error
)
validators := []func() (Warning, error){
func() (Warning, error) { return validateFeatureGates(c) },
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
}
for _, validator := range validators {
w, err := validator()
warnings = AppendError(warnings, w)
errs = AppendError(errs, err)
}
return warnings, errs
}
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
// validate feature gates
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
return warnings, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
var errs error
if !slices.Contains(SupportedAuthMethods, c.Method) {
if !slices.Contains(SupportedAuthMethods, c.Auth.Method) {
errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods))
}
if !lo.Every(SupportedAuthAdditionalScopes, c.AdditionalScopes) {
if !lo.Every(SupportedAuthAdditionalScopes, c.Auth.AdditionalScopes) {
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
// Validate token/tokenSource mutual exclusivity
if c.Token != "" && c.TokenSource != nil {
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.TokenSource != nil {
if c.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.TokenSource.Validate(); err != nil {
if c.Auth.TokenSource != nil {
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
}
return nil, errs
}
func (v *ConfigValidator) validateOIDCConfig(c *v1.AuthOIDCClientConfig) error {
if c.TokenSource == nil {
return nil
if err := validateWebServerConfig(&c.WebServer); err != nil {
errs = AppendError(errs, err)
}
var errs error
// Validate oidc.tokenSource mutual exclusivity with other fields of oidc
if c.ClientID != "" || c.ClientSecret != "" || c.Audience != "" ||
c.Scope != "" || c.TokenEndpointURL != "" || len(c.AdditionalEndpointParams) > 0 ||
c.TrustedCaFile != "" || c.InsecureSkipVerify || c.ProxyURL != "" {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc"))
}
if c.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.oidc.tokenSource: %v", err))
}
return errs
}
func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
var (
warnings Warning
errs error
)
if c.HeartbeatTimeout > 0 && c.HeartbeatInterval > 0 {
if c.HeartbeatTimeout < c.HeartbeatInterval {
if c.Transport.HeartbeatTimeout > 0 && c.Transport.HeartbeatInterval > 0 {
if c.Transport.HeartbeatTimeout < c.Transport.HeartbeatInterval {
errs = AppendError(errs, fmt.Errorf("invalid transport.heartbeatTimeout, heartbeat timeout should not less than heartbeat interval"))
}
}
if !lo.FromPtr(c.TLS.Enable) {
if !lo.FromPtr(c.Transport.TLS.Enable) {
checkTLSConfig := func(name string, value string) Warning {
if value != "" {
return fmt.Errorf("%s is invalid when transport.tls.enable is false", name)
@@ -133,20 +79,16 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
return nil
}
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.Transport.TLS.CertFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.Transport.TLS.KeyFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.Transport.TLS.TrustedCaFile))
}
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
if !slices.Contains(SupportedTransportProtocols, c.Transport.Protocol) {
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
}
return warnings, errs
}
func validateIncludeFiles(files []string) (Warning, error) {
var errs error
for _, f := range files {
for _, f := range c.IncludeConfigFiles {
absDir, err := filepath.Abs(filepath.Dir(f))
if err != nil {
errs = AppendError(errs, fmt.Errorf("include: parse directory of %s failed: %v", f, err))
@@ -156,19 +98,13 @@ func validateIncludeFiles(files []string) (Warning, error) {
errs = AppendError(errs, fmt.Errorf("include: directory of %s not exist", f))
}
}
return nil, errs
return warnings, errs
}
func ValidateAllClientConfig(
c *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
) (Warning, error) {
validator := NewConfigValidator(unsafeFeatures)
func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) {
var warnings Warning
if c != nil {
warning, err := validator.ValidateClientCommonConfig(c)
warning, err := ValidateClientCommonConfig(c)
warnings = AppendError(warnings, warning)
if err != nil {
return warnings, err

View File

@@ -21,10 +21,9 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
var (
warnings Warning
errs error
@@ -43,11 +42,6 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if c.Auth.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}

View File

@@ -1,28 +0,0 @@
package validation
import (
"fmt"
"github.com/fatedier/frp/pkg/policy/security"
)
// ConfigValidator holds the context dependencies for configuration validation.
type ConfigValidator struct {
unsafeFeatures *security.UnsafeFeatures
}
// NewConfigValidator creates a new ConfigValidator instance.
func NewConfigValidator(unsafeFeatures *security.UnsafeFeatures) *ConfigValidator {
return &ConfigValidator{
unsafeFeatures: unsafeFeatures,
}
}
// ValidateUnsafeFeature checks if a specific unsafe feature is enabled.
func (v *ConfigValidator) ValidateUnsafeFeature(feature string) error {
if !v.unsafeFeatures.IsEnabled(feature) {
return fmt.Errorf("unsafe feature %q is not enabled. "+
"To enable it, ensure it is allowed in the configuration or command line flags", feature)
}
return nil
}

View File

@@ -19,7 +19,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
@@ -28,7 +27,6 @@ import (
type ValueSource struct {
Type string `json:"type"`
File *FileSource `json:"file,omitempty"`
Exec *ExecSource `json:"exec,omitempty"`
}
// FileSource specifies how to load a value from a file.
@@ -36,18 +34,6 @@ type FileSource struct {
Path string `json:"path"`
}
// ExecSource specifies how to get a value from another program launched as subprocess.
type ExecSource struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env []ExecEnvVar `json:"env,omitempty"`
}
type ExecEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Validate validates the ValueSource configuration.
func (v *ValueSource) Validate() error {
if v == nil {
@@ -60,13 +46,8 @@ func (v *ValueSource) Validate() error {
return errors.New("file configuration is required when type is 'file'")
}
return v.File.Validate()
case "exec":
if v.Exec == nil {
return errors.New("exec configuration is required when type is 'exec'")
}
return v.Exec.Validate()
default:
return fmt.Errorf("unsupported value source type: %s (only 'file' and 'exec' are supported)", v.Type)
return fmt.Errorf("unsupported value source type: %s (only 'file' is supported)", v.Type)
}
}
@@ -79,8 +60,6 @@ func (v *ValueSource) Resolve(ctx context.Context) (string, error) {
switch v.Type {
case "file":
return v.File.Resolve(ctx)
case "exec":
return v.Exec.Resolve(ctx)
default:
return "", fmt.Errorf("unsupported value source type: %s", v.Type)
}
@@ -112,47 +91,3 @@ func (f *FileSource) Resolve(_ context.Context) (string, error) {
// Trim whitespace, which is important for file-based tokens
return strings.TrimSpace(string(content)), nil
}
// Validate validates the ExecSource configuration.
func (e *ExecSource) Validate() error {
if e == nil {
return errors.New("execSource cannot be nil")
}
if e.Command == "" {
return errors.New("exec command cannot be empty")
}
for _, env := range e.Env {
if env.Name == "" {
return errors.New("exec env name cannot be empty")
}
if strings.Contains(env.Name, "=") {
return errors.New("exec env name cannot contain '='")
}
}
return nil
}
// Resolve reads and returns the content captured from stdout of launched subprocess.
func (e *ExecSource) Resolve(ctx context.Context) (string, error) {
if err := e.Validate(); err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, e.Command, e.Args...)
if len(e.Env) != 0 {
cmd.Env = os.Environ()
for _, env := range e.Env {
cmd.Env = append(cmd.Env, env.Name+"="+env.Value)
}
}
content, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to execute command %v: %v", e.Command, err)
}
// Trim whitespace, which is important for exec-based tokens
return strings.TrimSpace(string(content)), nil
}

View File

@@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() {
}
}
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
func (m *serverMetrics) NewProxy(name string, proxyType string) {
for _, v := range m.ms {
v.NewProxy(name, proxyType, user, clientID)
v.NewProxy(name, proxyType)
}
}

View File

@@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() {
m.info.ClientCounts.Dec(1)
}
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
func (m *serverMetrics) NewProxy(name string, proxyType string) {
m.mu.Lock()
defer m.mu.Unlock()
counter, ok := m.info.ProxyTypeCounts[proxyType]
@@ -119,8 +119,6 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
}
m.info.ProxyStatistics[name] = proxyStats
}
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = time.Now()
}
@@ -216,8 +214,6 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
ps := &ProxyStats{
Name: name,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
@@ -249,8 +245,6 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
res = &ProxyStats{
Name: name,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
@@ -266,31 +260,6 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
return
}
func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
m.mu.Lock()
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
res = &ProxyStats{
Name: proxyName,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
}
if !proxyStats.LastStartTime.IsZero() {
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
}
if !proxyStats.LastCloseTime.IsZero() {
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
}
}
return
}
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@@ -35,8 +35,6 @@ type ServerStats struct {
type ProxyStats struct {
Name string
Type string
User string
ClientID string
TodayTrafficIn int64
TodayTrafficOut int64
LastStartTime string
@@ -53,8 +51,6 @@ type ProxyTrafficInfo struct {
type ProxyStatistics struct {
Name string
ProxyType string
User string
ClientID string
TrafficIn metric.DateCounter
TrafficOut metric.DateCounter
CurConns metric.Counter
@@ -82,7 +78,6 @@ type Collector interface {
GetServer() *ServerStats
GetProxiesByType(proxyType string) []*ProxyStats
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
}

View File

@@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() {
m.clientCount.Dec()
}
func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {
func (m *serverMetrics) NewProxy(name string, proxyType string) {
m.proxyCount.WithLabelValues(proxyType).Inc()
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
}

View File

@@ -86,6 +86,10 @@ func (d *Dispatcher) Send(m Message) error {
}
}
func (d *Dispatcher) SendChannel() chan Message {
return d.sendCh
}
func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
d.msgHandlers[reflect.TypeOf(msg)] = handler
}

View File

@@ -82,7 +82,6 @@ type Login struct {
PrivilegeKey string `json:"privilege_key,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
RunID string `json:"run_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
Metas map[string]string `json:"metas,omitempty"`
// Currently only effective for VirtualClient.

View File

@@ -1,34 +0,0 @@
package security
const (
TokenSourceExec = "TokenSourceExec"
)
var (
ClientUnsafeFeatures = []string{
TokenSourceExec,
}
ServerUnsafeFeatures = []string{
TokenSourceExec,
}
)
type UnsafeFeatures struct {
features map[string]bool
}
func NewUnsafeFeatures(allowed []string) *UnsafeFeatures {
features := make(map[string]bool)
for _, f := range allowed {
features[f] = true
}
return &UnsafeFeatures{features: features}
}
func (u *UnsafeFeatures) IsEnabled(feature string) bool {
if u == nil {
return false
}
return u.features[feature]
}

View File

@@ -124,8 +124,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
}
mu.Unlock()
// Add proxy protocol header if configured (only for the first packet of a new connection)
if !ok && proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
// Add proxy protocol header if configured
if proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)
if err == nil {
// Prepend proxy protocol header to the UDP payload

View File

@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/fatedier/frp/client/api"
"github.com/fatedier/frp/client"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
@@ -32,7 +32,7 @@ func (c *Client) SetAuth(user, pwd string) {
c.authPwd = pwd
}
func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxyStatusResp, error) {
func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.ProxyStatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -41,7 +41,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
if err != nil {
return nil, err
}
allStatus := make(api.StatusResp)
allStatus := make(client.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}
@@ -55,7 +55,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
return nil, fmt.Errorf("no proxy status found")
}
func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) {
func (c *Client) GetAllProxyStatus(ctx context.Context) (client.StatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -64,7 +64,7 @@ func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error)
if err != nil {
return nil, err
}
allStatus := make(api.StatusResp)
allStatus := make(client.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}

View File

@@ -35,19 +35,15 @@ type MessageTransporter interface {
DispatchWithType(m msg.Message, msgType, laneKey string) bool
}
type MessageSender interface {
Send(msg.Message) error
}
func NewMessageTransporter(sender MessageSender) MessageTransporter {
func NewMessageTransporter(sendCh chan msg.Message) MessageTransporter {
return &transporterImpl{
sender: sender,
sendCh: sendCh,
registry: make(map[string]map[string]chan msg.Message),
}
}
type transporterImpl struct {
sender MessageSender
sendCh chan msg.Message
// First key is message type and second key is lane key.
// Dispatch will dispatch message to related channel by its message type
@@ -57,7 +53,9 @@ type transporterImpl struct {
}
func (impl *transporterImpl) Send(m msg.Message) error {
return impl.sender.Send(m)
return errors.PanicToError(func() {
impl.sendCh <- m
})
}
func (impl *transporterImpl) Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) {

View File

@@ -1,57 +0,0 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"io"
"net/http"
"github.com/gorilla/mux"
)
type Context struct {
Req *http.Request
Resp http.ResponseWriter
vars map[string]string
}
func NewContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{
Req: r,
Resp: w,
vars: mux.Vars(r),
}
}
func (c *Context) Param(key string) string {
return c.vars[key]
}
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
func (c *Context) BindJSON(obj any) error {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return err
}
return json.Unmarshal(body, obj)
}
func (c *Context) Body() ([]byte, error) {
return io.ReadAll(c.Req.Body)
}

View File

@@ -1,33 +0,0 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import "fmt"
type Error struct {
Code int
Err error
}
func (e *Error) Error() string {
return e.Err.Error()
}
func NewError(code int, msg string) *Error {
return &Error{
Code: code,
Err: fmt.Errorf("%s", msg),
}
}

View File

@@ -1,66 +0,0 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"net/http"
"github.com/fatedier/frp/pkg/util/log"
)
type GeneralResponse struct {
Code int
Msg string
}
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
// MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc.
func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
res, err := handler(ctx)
if err != nil {
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
code := http.StatusInternalServerError
if e, ok := err.(*Error); ok {
code = e.Code
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()})
return
}
if res == nil {
w.WriteHeader(http.StatusOK)
return
}
switch v := res.(type) {
case []byte:
_, _ = w.Write(v)
case string:
_, _ = w.Write([]byte(v))
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
}
}

View File

@@ -1,40 +0,0 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"net/http"
"github.com/fatedier/frp/pkg/util/log"
)
type responseWriter struct {
http.ResponseWriter
code int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.code = code
rw.ResponseWriter.WriteHeader(code)
}
func NewRequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Infof("http request: [%s]", r.URL.Path)
rw := &responseWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
log.Infof("http response [%s]: code [%d]", r.URL.Path, rw.code)
})
}

View File

@@ -14,7 +14,7 @@
package version
var version = "0.67.0"
var version = "0.65.0"
func Full() string {
return version

View File

@@ -1,424 +0,0 @@
// Copyright 2025 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 api
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type Controller struct {
// dependencies
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
pxyManager ProxyManager
}
type ProxyManager interface {
GetByName(name string) (proxy.Proxy, bool)
}
func NewController(
serverCfg *v1.ServerConfig,
clientRegistry *registry.ClientRegistry,
pxyManager ProxyManager,
) *Controller {
return &Controller{
serverCfg: serverCfg,
clientRegistry: clientRegistry,
pxyManager: pxyManager,
}
}
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer()
svrResp := ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
VhostHTTPSPort: c.serverCfg.VhostHTTPSPort,
TCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort,
KCPBindPort: c.serverCfg.KCPBindPort,
QUICBindPort: c.serverCfg.QUICBindPort,
SubdomainHost: c.serverCfg.SubDomainHost,
MaxPoolCount: c.serverCfg.Transport.MaxPoolCount,
MaxPortsPerClient: c.serverCfg.MaxPortsPerClient,
HeartBeatTimeout: c.serverCfg.Transport.HeartbeatTimeout,
AllowPortsStr: types.PortsRangeSlice(c.serverCfg.AllowPorts).String(),
TLSForce: c.serverCfg.Transport.TLS.Force,
TotalTrafficIn: serverStats.TotalTrafficIn,
TotalTrafficOut: serverStats.TotalTrafficOut,
CurConns: serverStats.CurConns,
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
// For API that returns struct, we can just return it.
// But current GeneralResponse.Msg in legacy code expects a JSON string.
// Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
// The original code wraps it in GeneralResponse{Msg: string(json)}.
// If we return svrResp, the response body will be the JSON of svrResp.
// We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
// Looking at previous code:
// res := GeneralResponse{Code: 200}
// buf, _ := json.Marshal(&svrResp)
// res.Msg = string(buf)
// Response body: {"code": 200, "msg": "{\"version\":...}"}
// Wait, is it double encoded JSON? Yes it seems so!
// Let's check dashboard_api.go original code again.
// Yes: res.Msg = string(buf).
// So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
// This is kind of ugly, but we must preserve compatibility.
return svrResp, nil
}
// /api/clients
func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
userFilter := ctx.Query("user")
clientIDFilter := ctx.Query("clientId")
runIDFilter := ctx.Query("runId")
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
items := make([]ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchStatusFilter(info.Online, statusFilter) {
continue
}
items = append(items, buildClientInfoResp(info))
}
slices.SortFunc(items, func(a, b ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
return items, nil
}
// /api/clients/{key}
func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
key := ctx.Param("key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
return buildClientInfoResp(info), nil
}
// /api/proxy/:type
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
return proxyInfoResp, nil
}
// /api/proxy/:type/:name
func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
name := ctx.Param("name")
proxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name)
if code != 200 {
return nil, httppkg.NewError(code, msg)
}
return proxyStatsResp, nil
}
// /api/traffic/:name
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
trafficResp := GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
return trafficResp, nil
}
// /api/proxies/:name
func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
proxyInfo := GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{
clientVersion: &proxyInfo.ClientVersion,
}, pxy)
} else {
proxyInfo.Status = "offline"
}
return proxyInfo, nil
}
// DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status")
if status != "offline" {
return nil, httppkg.NewError(http.StatusBadRequest, "status only support offline")
}
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
return nil, nil
}
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{
clientVersion: &proxyInfo.ClientVersion,
}, pxy)
} else {
proxyInfo.Status = "offline"
}
proxyInfo.Name = ps.Name
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
proxyInfos = append(proxyInfos, proxyInfo)
}
return
}
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
code = 404
msg = "no proxy info found"
} else {
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
code = 200
}
return
}
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
resp := ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
RunID: info.RunID,
Hostname: info.Hostname,
ClientIP: info.IP,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
LastConnectedAt: toUnix(info.LastConnectedAt),
Online: info.Online,
}
if !info.DisconnectedAt.IsZero() {
resp.DisconnectedAt = info.DisconnectedAt.Unix()
}
return resp
}
type proxyClientInfo struct {
user *string
clientID *string
clientVersion *string
}
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
loginMsg := pxy.GetLoginMsg()
if loginMsg == nil {
return
}
if proxyInfo.user != nil {
*proxyInfo.user = loginMsg.User
}
if proxyInfo.clientVersion != nil {
*proxyInfo.clientVersion = loginMsg.Version
}
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
if proxyInfo.clientID != nil {
*proxyInfo.clientID = info.ClientID()
}
return
}
if proxyInfo.clientID != nil {
*proxyInfo.clientID = loginMsg.ClientID
if *proxyInfo.clientID == "" {
*proxyInfo.clientID = loginMsg.RunID
}
}
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func matchStatusFilter(online bool, filter string) bool {
switch strings.ToLower(filter) {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}
func getConfByType(proxyType string) any {
switch v1.ProxyType(proxyType) {
case v1.ProxyTypeTCP:
return &TCPOutConf{}
case v1.ProxyTypeTCPMUX:
return &TCPMuxOutConf{}
case v1.ProxyTypeUDP:
return &UDPOutConf{}
case v1.ProxyTypeHTTP:
return &HTTPOutConf{}
case v1.ProxyTypeHTTPS:
return &HTTPSOutConf{}
case v1.ProxyTypeSTCP:
return &STCPOutConf{}
case v1.ProxyTypeXTCP:
return &XTCPOutConf{}
default:
return nil
}
}

View File

@@ -1,136 +0,0 @@
// Copyright 2025 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 api
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type ServerInfoResp struct {
Version string `json:"version"`
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr,omitempty"`
TLSForce bool `json:"tlsForce,omitempty"`
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type ClientInfoResp struct {
Key string `json:"key"`
User string `json:"user"`
ClientID string `json:"clientID"`
RunID string `json:"runID"`
Hostname string `json:"hostname"`
ClientIP string `json:"clientIP,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
LastConnectedAt int64 `json:"lastConnectedAt"`
DisconnectedAt int64 `json:"disconnectedAt,omitempty"`
Online bool `json:"online"`
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
type TCPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type TCPMuxOutConf struct {
BaseOutConf
v1.DomainConfig
Multiplexer string `json:"multiplexer"`
RouteByHTTPUser string `json:"routeByHTTPUser"`
}
type UDPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type HTTPOutConf struct {
BaseOutConf
v1.DomainConfig
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"hostHeaderRewrite"`
}
type HTTPSOutConf struct {
BaseOutConf
v1.DomainConfig
}
type STCPOutConf struct {
BaseOutConf
}
type XTCPOutConf struct {
BaseOutConf
}
// Get proxy info.
type ProxyStatsInfo struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
type GetProxyInfoResp struct {
Proxies []*ProxyStatsInfo `json:"proxies"`
}
// Get proxy info by name.
type GetProxyStatsResp struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
// /api/traffic/:name
type GetProxyTrafficResp struct {
Name string `json:"name"`
TrafficIn []int64 `json:"trafficIn"`
TrafficOut []int64 `json:"trafficOut"`
}

View File

@@ -40,7 +40,6 @@ import (
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/metrics"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type ControlManager struct {
@@ -107,8 +106,6 @@ type Control struct {
// verifies authentication based on selected method
authVerifier auth.Verifier
// key used for connection encryption
encryptionKey []byte
// other components can use this to communicate with client
msgTransporter transport.MessageTransporter
@@ -148,8 +145,6 @@ type Control struct {
// Server configuration information
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
@@ -162,7 +157,6 @@ func NewControl(
pxyManager *proxy.Manager,
pluginManager *plugin.Manager,
authVerifier auth.Verifier,
encryptionKey []byte,
ctlConn net.Conn,
ctlConnEncrypted bool,
loginMsg *msg.Login,
@@ -177,7 +171,6 @@ func NewControl(
pxyManager: pxyManager,
pluginManager: pluginManager,
authVerifier: authVerifier,
encryptionKey: encryptionKey,
conn: ctlConn,
loginMsg: loginMsg,
workConnCh: make(chan net.Conn, poolCount+10),
@@ -193,7 +186,7 @@ func NewControl(
ctl.lastPing.Store(time.Now())
if ctlConnEncrypted {
cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, ctl.encryptionKey)
cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token))
if err != nil {
return nil, err
}
@@ -202,7 +195,7 @@ func NewControl(
ctl.msgDispatcher = msg.NewDispatcher(ctl.conn)
}
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel())
return ctl, nil
}
@@ -361,7 +354,6 @@ func (ctl *Control) worker() {
}
metrics.Server.CloseClient()
ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}
@@ -405,11 +397,7 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
} else {
resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
clientID := ctl.loginMsg.ClientID
if clientID == "" {
clientID = ctl.loginMsg.RunID
}
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
}
_ = ctl.msgDispatcher.Send(resp)
}
@@ -490,7 +478,6 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
GetWorkConnFn: ctl.GetWorkConn,
Configurer: pxyConf,
ServerCfg: ctl.serverCfg,
EncryptionKey: ctl.encryptionKey,
})
if err != nil {
return remoteAddr, err

406
server/dashboard_api.go Normal file
View File

@@ -0,0 +1,406 @@
// Copyright 2017 fatedier, fatedier@gmail.com
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"cmp"
"encoding/json"
"net/http"
"slices"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/version"
)
type GeneralResponse struct {
Code int
Msg string
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", svr.healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware.Middleware)
// metrics
if svr.cfg.EnablePrometheus {
subRouter.Handle("/metrics", promhttp.Handler())
}
// apis
subRouter.HandleFunc("/api/serverinfo", svr.apiServerInfo).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET")
subRouter.HandleFunc("/api/proxies", svr.deleteProxies).Methods("DELETE")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
).Methods("GET")
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
})
}
type serverInfoResp struct {
Version string `json:"version"`
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr,omitempty"`
TLSForce bool `json:"tlsForce,omitempty"`
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}
// /api/serverinfo
func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
serverStats := mem.StatsCollector.GetServer()
svrResp := serverInfoResp{
Version: version.Full(),
BindPort: svr.cfg.BindPort,
VhostHTTPPort: svr.cfg.VhostHTTPPort,
VhostHTTPSPort: svr.cfg.VhostHTTPSPort,
TCPMuxHTTPConnectPort: svr.cfg.TCPMuxHTTPConnectPort,
KCPBindPort: svr.cfg.KCPBindPort,
QUICBindPort: svr.cfg.QUICBindPort,
SubdomainHost: svr.cfg.SubDomainHost,
MaxPoolCount: svr.cfg.Transport.MaxPoolCount,
MaxPortsPerClient: svr.cfg.MaxPortsPerClient,
HeartBeatTimeout: svr.cfg.Transport.HeartbeatTimeout,
AllowPortsStr: types.PortsRangeSlice(svr.cfg.AllowPorts).String(),
TLSForce: svr.cfg.Transport.TLS.Force,
TotalTrafficIn: serverStats.TotalTrafficIn,
TotalTrafficOut: serverStats.TotalTrafficOut,
CurConns: serverStats.CurConns,
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
buf, _ := json.Marshal(&svrResp)
res.Msg = string(buf)
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
type TCPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type TCPMuxOutConf struct {
BaseOutConf
v1.DomainConfig
Multiplexer string `json:"multiplexer"`
RouteByHTTPUser string `json:"routeByHTTPUser"`
}
type UDPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type HTTPOutConf struct {
BaseOutConf
v1.DomainConfig
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"hostHeaderRewrite"`
}
type HTTPSOutConf struct {
BaseOutConf
v1.DomainConfig
}
type STCPOutConf struct {
BaseOutConf
}
type XTCPOutConf struct {
BaseOutConf
}
func getConfByType(proxyType string) any {
switch v1.ProxyType(proxyType) {
case v1.ProxyTypeTCP:
return &TCPOutConf{}
case v1.ProxyTypeTCPMUX:
return &TCPMuxOutConf{}
case v1.ProxyTypeUDP:
return &UDPOutConf{}
case v1.ProxyTypeHTTP:
return &HTTPOutConf{}
case v1.ProxyTypeHTTPS:
return &HTTPSOutConf{}
case v1.ProxyTypeSTCP:
return &STCPOutConf{}
case v1.ProxyTypeXTCP:
return &XTCPOutConf{}
default:
return nil
}
}
// Get proxy info.
type ProxyStatsInfo struct {
Name string `json:"name"`
Conf any `json:"conf"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
type GetProxyInfoResp struct {
Proxies []*ProxyStatsInfo `json:"proxies"`
}
// /api/proxy/:type
func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
proxyType := params["type"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp.Proxies = svr.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
buf, _ := json.Marshal(&proxyInfoResp)
res.Msg = string(buf)
}
func (svr *Service) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{}
if pxy, ok := svr.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Status = "online"
if pxy.GetLoginMsg() != nil {
proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
}
} else {
proxyInfo.Status = "offline"
}
proxyInfo.Name = ps.Name
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
proxyInfos = append(proxyInfos, proxyInfo)
}
return
}
// Get proxy info by name.
type GetProxyStatsResp struct {
Name string `json:"name"`
Conf any `json:"conf"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
// /api/proxy/:type/:name
func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
proxyType := params["type"]
name := params["name"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
var proxyStatsResp GetProxyStatsResp
proxyStatsResp, res.Code, res.Msg = svr.getProxyStatsByTypeAndName(proxyType, name)
if res.Code != 200 {
return
}
buf, _ := json.Marshal(&proxyStatsResp)
res.Msg = string(buf)
}
func (svr *Service) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
code = 404
msg = "no proxy info found"
} else {
if pxy, ok := svr.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
code = 200
}
return
}
// /api/traffic/:name
type GetProxyTrafficResp struct {
Name string `json:"name"`
TrafficIn []int64 `json:"trafficIn"`
TrafficOut []int64 `json:"trafficOut"`
}
func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
name := params["name"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
trafficResp := GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
res.Code = 404
res.Msg = "no proxy info found"
return
}
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
buf, _ := json.Marshal(&trafficResp)
res.Msg = string(buf)
}
// DELETE /api/proxies?status=offline
func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http request: [%s]", r.URL.Path)
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
status := r.URL.Query().Get("status")
if status != "offline" {
res.Code = 400
res.Msg = "status only support offline"
return
}
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
}

View File

@@ -7,7 +7,7 @@ import (
type ServerMetrics interface {
NewClient()
CloseClient()
NewProxy(name string, proxyType string, user string, clientID string)
NewProxy(name string, proxyType string)
CloseProxy(name string, proxyType string)
OpenConnection(name string, proxyType string)
CloseConnection(name string, proxyType string)
@@ -27,11 +27,11 @@ func Register(m ServerMetrics) {
type noopServerMetrics struct{}
func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string, string, string) {}
func (noopServerMetrics) CloseProxy(string, string) {}
func (noopServerMetrics) OpenConnection(string, string) {}
func (noopServerMetrics) CloseConnection(string, string) {}
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string) {}
func (noopServerMetrics) CloseProxy(string, string) {}
func (noopServerMetrics) OpenConnection(string, string) {}
func (noopServerMetrics) CloseConnection(string, string) {}
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}

View File

@@ -165,7 +165,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
var rwc io.ReadWriteCloser = tmpConn
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
rwc, err = libio.WithEncryption(rwc, []byte(pxy.serverCfg.Auth.Token))
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
return

View File

@@ -68,7 +68,6 @@ type BaseProxy struct {
poolCount int
getWorkConnFn GetWorkConnFn
serverCfg *v1.ServerConfig
encryptionKey []byte
limiter *rate.Limiter
userInfo plugin.UserInfo
loginMsg *msg.Login
@@ -214,6 +213,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
xl := xlog.FromContextSafe(pxy.Context())
defer userConn.Close()
serverCfg := pxy.serverCfg
cfg := pxy.configurer.GetBaseConfig()
// server plugin hook
rc := pxy.GetResourceController()
@@ -240,7 +240,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
xl.Tracef("handler user tcp connection, use_encryption: %t, use_compression: %t",
cfg.Transport.UseEncryption, cfg.Transport.UseCompression)
if cfg.Transport.UseEncryption {
local, err = libio.WithEncryption(local, pxy.encryptionKey)
local, err = libio.WithEncryption(local, []byte(serverCfg.Auth.Token))
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
return
@@ -279,7 +279,6 @@ type Options struct {
GetWorkConnFn GetWorkConnFn
Configurer v1.ProxyConfigurer
ServerCfg *v1.ServerConfig
EncryptionKey []byte
}
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -299,7 +298,6 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
poolCount: options.PoolCount,
getWorkConnFn: options.GetWorkConnFn,
serverCfg: options.ServerCfg,
encryptionKey: options.EncryptionKey,
limiter: limiter,
xl: xl,
ctx: xlog.NewContext(ctx, xl),

View File

@@ -205,7 +205,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
var rwc io.ReadWriteCloser = workConn
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
rwc, err = libio.WithEncryption(rwc, []byte(pxy.serverCfg.Auth.Token))
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
workConn.Close()

View File

@@ -1,179 +0,0 @@
// Copyright 2025 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 registry
import (
"fmt"
"sync"
"time"
)
// ClientInfo captures metadata about a connected frpc instance.
type ClientInfo struct {
Key string
User string
RawClientID string
RunID string
Hostname string
IP string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
Online bool
}
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty).
// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.
type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
}
func NewClientRegistry() *ClientRegistry {
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
runIndex: make(map[string]string),
}
}
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
if runID == "" {
return "", false
}
effectiveID := rawClientID
if effectiveID == "" {
effectiveID = runID
}
key = cr.composeClientKey(user, effectiveID)
enforceUnique := rawClientID != ""
now := time.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
info, exists := cr.clients[key]
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
return key, true
}
if !exists {
info = &ClientInfo{
Key: key,
User: user,
FirstConnectedAt: now,
}
cr.clients[key] = info
} else if info.RunID != "" {
delete(cr.runIndex, info.RunID)
}
info.RawClientID = rawClientID
info.RunID = runID
info.Hostname = hostname
info.IP = remoteAddr
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
info.LastConnectedAt = now
info.DisconnectedAt = time.Time{}
info.Online = true
cr.runIndex[runID] = key
return key, false
}
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
cr.mu.Lock()
defer cr.mu.Unlock()
key, ok := cr.runIndex[runID]
if !ok {
return
}
if info, ok := cr.clients[key]; ok && info.RunID == runID {
if info.RawClientID == "" {
delete(cr.clients, key)
} else {
info.RunID = ""
info.Online = false
now := time.Now()
info.DisconnectedAt = now
}
}
delete(cr.runIndex, runID)
}
// List returns a snapshot of all known clients.
func (cr *ClientRegistry) List() []ClientInfo {
cr.mu.RLock()
defer cr.mu.RUnlock()
result := make([]ClientInfo, 0, len(cr.clients))
for _, info := range cr.clients {
result = append(result, *info)
}
return result
}
// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
// ClientID returns the resolved client identifier for external use.
func (info ClientInfo) ClientID() string {
if info.RawClientID != "" {
return info.RawClientID
}
return info.RunID
}
// GetByRunID retrieves a client by its run ID.
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
key, ok := cr.runIndex[runID]
if !ok {
return ClientInfo{}, false
}
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch {
case user == "":
return id
case id == "":
return user
default:
return fmt.Sprintf("%s.%s", user, id)
}
}

View File

@@ -28,7 +28,6 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -48,13 +47,11 @@ import (
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics"
"github.com/fatedier/frp/server/ports"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
"github.com/fatedier/frp/server/visitor"
)
@@ -99,9 +96,6 @@ type Service struct {
// Manage all controllers
ctlManager *ControlManager
// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
clientRegistry *registry.ClientRegistry
// Manage all proxies
pxyManager *proxy.Manager
@@ -119,8 +113,8 @@ type Service struct {
sshTunnelGateway *ssh.Gateway
// Auth runtime and encryption materials
auth *auth.ServerAuth
// Verifies authentication based on selected method
authVerifier auth.Verifier
tlsConfig *tls.Config
@@ -155,16 +149,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
}
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
if err != nil {
return nil, err
}
svr := &Service{
ctlManager: NewControlManager(),
clientRegistry: registry.NewClientRegistry(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
ctlManager: NewControlManager(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
rc: &controller.ResourceController{
VisitorManager: visitor.NewManager(),
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
@@ -172,7 +160,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
},
sshTunnelListener: netpkg.NewInternalListener(),
httpVhostRouter: vhost.NewRouters(),
auth: authRuntime,
authVerifier: auth.NewAuthVerifier(cfg.Auth),
webServer: webServer,
tlsConfig: tlsConfig,
cfg: cfg,
@@ -598,7 +586,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
ctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch)
// Check auth.
authVerifier := svr.auth.Verifier
authVerifier := svr.authVerifier
if internal && loginMsg.ClientSpec.AlwaysAuthPass {
authVerifier = auth.AlwaysPassVerifier
}
@@ -607,29 +595,16 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
}
// TODO(fatedier): use SessionContext
ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, svr.auth.EncryptionKey(), ctlConn, !internal, loginMsg, svr.cfg)
ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, ctlConn, !internal, loginMsg, svr.cfg)
if err != nil {
xl.Warnf("create new controller error: %v", err)
// don't return detailed errors to client
return fmt.Errorf("unexpected error when creating new controller")
}
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
oldCtl.WaitClosed()
}
remoteAddr := ctlConn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
ctl.Close()
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
ctl.clientRegistry = svr.clientRegistry
ctl.Start()
// for statistics
@@ -690,42 +665,3 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware)
subRouter.Use(httppkg.NewRequestLogger)
// metrics
if svr.cfg.EnablePrometheus {
subRouter.Handle("/metrics", promhttp.Handler())
}
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
// apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
).Methods("GET")
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
})
}
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}

View File

@@ -29,18 +29,6 @@ import (
var _ = ginkgo.Describe("[Feature: TokenSource]", func() {
f := framework.NewDefaultFramework()
createExecTokenScript := func(name string) string {
scriptPath := filepath.Join(f.TempDirectory, name)
scriptContent := `#!/bin/sh
printf '%s\n' "$1"
`
err := os.WriteFile(scriptPath, []byte(scriptContent), 0o600)
framework.ExpectNoError(err)
err = os.Chmod(scriptPath, 0o700)
framework.ExpectNoError(err)
return scriptPath
}
ginkgo.Describe("File-based token loading", func() {
ginkgo.It("should work with file tokenSource", func() {
// Create a temporary token file
@@ -226,154 +214,4 @@ auth.tokenSource.file.path = "%s"
f.RunProcesses([]string{serverConf}, []string{})
})
})
ginkgo.Describe("Exec-based token loading", func() {
ginkgo.It("should work with server tokenSource", func() {
execValue := "exec-server-value"
scriptPath := createExecTokenScript("server_token_exec.sh")
serverPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
auth.tokenSource.type = "exec"
auth.tokenSource.exec.command = %q
auth.tokenSource.exec.args = [%q]
`, serverPort, scriptPath, execValue)
clientConf := fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
auth.token = %q
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, serverPort, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)
serverConfigPath := f.GenerateConfigFile(serverConf)
clientConfigPath := f.GenerateConfigFile(clientConf)
_, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
_, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("should work with client tokenSource", func() {
execValue := "exec-client-value"
scriptPath := createExecTokenScript("client_token_exec.sh")
serverPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
auth.token = %q
`, serverPort, execValue)
clientConf := fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
auth.tokenSource.type = "exec"
auth.tokenSource.exec.command = %q
auth.tokenSource.exec.args = [%q]
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)
serverConfigPath := f.GenerateConfigFile(serverConf)
clientConfigPath := f.GenerateConfigFile(clientConf)
_, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
_, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("should work with both server and client tokenSource", func() {
execValue := "exec-shared-value"
scriptPath := createExecTokenScript("shared_token_exec.sh")
serverPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
auth.tokenSource.type = "exec"
auth.tokenSource.exec.command = %q
auth.tokenSource.exec.args = [%q]
`, serverPort, scriptPath, execValue)
clientConf := fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
auth.tokenSource.type = "exec"
auth.tokenSource.exec.command = %q
auth.tokenSource.exec.args = [%q]
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)
serverConfigPath := f.GenerateConfigFile(serverConf)
clientConfigPath := f.GenerateConfigFile(clientConf)
_, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
_, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec")
framework.ExpectNoError(err)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("should fail validation without allow-unsafe", func() {
execValue := "exec-unsafe-value"
scriptPath := createExecTokenScript("unsafe_token_exec.sh")
serverPort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
auth.tokenSource.type = "exec"
auth.tokenSource.exec.command = %q
auth.tokenSource.exec.args = [%q]
`, serverPort, scriptPath, execValue)
serverConfigPath := f.GenerateConfigFile(serverConf)
_, output, err := f.RunFrps("verify", "-c", serverConfigPath)
framework.ExpectNoError(err)
framework.ExpectContainSubstring(output, "unsafe feature \"TokenSourceExec\" is not enabled")
})
})
})

View File

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

View File

@@ -7,22 +7,18 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ClientConfigure: typeof import('./src/components/ClientConfigure.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
Overview: typeof import('./src/components/Overview.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/StatCard.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>frp client</title>
<title>frp client admin UI</title>
</head>
<body>

File diff suppressed because it is too large Load Diff

View File

@@ -11,30 +11,25 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"element-plus": "^2.13.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"element-plus": "^2.5.3",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
"@rushstack/eslint-patch": "^1.7.2",
"@types/node": "^18.11.12",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue": "^9.21.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",
"sass": "^1.97.2",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"prettier": "^3.2.4",
"typescript": "~5.3.3",
"unplugin-auto-import": "^0.17.5",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^0.26.0",
"vite": "^7.3.0",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
"vite": "^5.0.12",
"vue-tsc": "^1.8.27"
}
}
}

View File

@@ -1,265 +1,116 @@
<template>
<div id="app">
<header class="header">
<header class="grid-content header-color">
<div class="header-content">
<div class="header-top">
<div class="brand-section">
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge client-badge">Client</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
<div class="brand">
<a href="#">frp client</a>
</div>
<div class="dark-switch">
<el-switch
v-model="darkmodeSwitch"
inline-prompt
active-text="Dark"
inactive-text="Light"
@change="toggleDark"
style="
--el-switch-on-color: #444452;
--el-switch-off-color: #589ef8;
"
/>
</div>
<nav class="nav-bar">
<router-link to="/" class="nav-link" active-class="active"
>Overview</router-link
>
<router-link to="/configure" class="nav-link" active-class="active"
>Configure</router-link
>
</nav>
</div>
</header>
<section>
<el-row>
<el-col id="side-nav" :xs="24" :md="4">
<el-menu
default-active="1"
mode="vertical"
theme="light"
router="false"
@select="handleSelect"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/configure">Configure</el-menu-item>
<el-menu-item index="">Help</el-menu-item>
</el-menu>
</el-col>
<main id="content">
<router-view></router-view>
</main>
<el-col :xs="24" :md="20">
<div id="content">
<router-view></router-view>
</div>
</el-col>
</el-row>
</section>
<footer></footer>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
import { ref } from 'vue'
import { useDark, useToggle } from '@vueuse/core'
const route = useRoute()
const isDark = useDark()
const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark)
const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
if (route.path === '/configure') return 'Configure'
return ''
})
const handleSelect = (key: string) => {
if (key == '') {
window.open('https://github.com/fatedier/frp')
}
}
</script>
<style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
margin: 0px;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--el-bg-color-page);
header {
width: 100%;
height: 60px;
}
.header {
position: sticky;
top: 0;
z-index: 100;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
.header-color {
background: #58b7ff;
}
html.dark .header-color {
background: #395c74;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
}
.header-top {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-section {
display: flex;
align-items: center;
gap: 12px;
}
.logo-wrapper {
display: flex;
align-items: center;
}
.logo-icon {
width: 32px;
height: 32px;
}
.divider {
color: var(--header-border);
font-size: 24px;
font-weight: 200;
}
.brand-name {
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.badge {
font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
}
.badge.client-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
font-weight: 500;
}
html.dark .badge.client-badge {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
.header-controls {
display: flex;
align-items: center;
gap: 16px;
}
.github-link {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-primary);
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
width: 18px;
height: 18px;
}
.github-link:hover {
background: var(--hover-bg);
border-color: var(--header-border);
}
.theme-switch {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
--el-switch-border-color: var(--header-border);
}
html.dark .theme-switch {
--el-switch-off-color: #333;
}
.theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important;
}
.nav-bar {
height: 48px;
display: flex;
align-items: center;
gap: 24px;
}
.nav-link {
text-decoration: none;
font-size: 14px;
color: var(--text-secondary);
padding: 8px 0;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.nav-link:hover {
color: var(--text-primary);
}
.nav-link.active {
color: var(--active-link);
border-bottom-color: var(--active-link);
}
#content {
flex: 1;
width: 100%;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
margin-top: 20px;
padding-right: 40px;
}
@media (max-width: 768px) {
.header-content {
padding: 0 20px;
}
.brand {
display: flex;
justify-content: flex-start;
}
#content {
padding: 20px;
}
.brand a {
color: #fff;
background-color: transparent;
margin-left: 20px;
line-height: 25px;
font-size: 25px;
padding: 15px 15px;
height: 30px;
text-decoration: none;
}
.dark-switch {
display: flex;
justify-content: flex-end;
flex-grow: 1;
padding-right: 40px;
}
</style>

View File

@@ -1,18 +0,0 @@
import { http } from './http'
import type { StatusResponse } from '../types/proxy'
export const getStatus = () => {
return http.get<StatusResponse>('/api/status')
}
export const getConfig = () => {
return http.get<string>('/api/config')
}
export const putConfig = (content: string) => {
return http.put<void>('/api/config', content)
}
export const reloadConfig = () => {
return http.get<void>('/api/reload')
}

View File

@@ -1,92 +0,0 @@
// http.ts - Base HTTP client
class HTTPError extends Error {
status: number
statusText: string
constructor(status: number, statusText: string, message?: string) {
super(message || statusText)
this.status = status
this.statusText = statusText
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
`HTTP ${response.status}`,
)
}
// Handle empty response (e.g. 204 No Content)
if (response.status === 204) {
return {} as T
}
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return response.text() as unknown as T
}
export const http = {
get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers }
let requestBody = body
if (
body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
}
return request<T>(url, {
...options,
method: 'POST',
headers,
body: requestBody,
})
},
put: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers }
let requestBody = body
if (
body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
}
return request<T>(url, {
...options,
method: 'PUT',
headers,
body: requestBody,
})
},
delete: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'DELETE' }),
}

View File

@@ -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;
}

View File

@@ -1,180 +0,0 @@
/* Dark Mode Theme */
html.dark {
--el-bg-color: #1e1e2e;
--el-bg-color-page: #1a1a2e;
--el-bg-color-overlay: #27293d;
--el-fill-color-blank: #1e1e2e;
background-color: #1a1a2e;
}
html.dark body {
background-color: #1a1a2e;
color: #e5e7eb;
}
/* Dark mode scrollbar */
html.dark ::-webkit-scrollbar-track {
background: #27293d;
}
html.dark ::-webkit-scrollbar-thumb {
background: #3a3d5c;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d6c;
}
/* Dark mode cards */
html.dark .el-card {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
}
/* Dark mode inputs */
html.dark .el-input__wrapper {
background-color: #27293d;
box-shadow: 0 0 0 1px #3a3d5c inset;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
html.dark .el-input__inner {
color: #e5e7eb;
}
html.dark .el-input__inner::placeholder {
color: #6b7280;
}
/* Dark mode textarea */
html.dark .el-textarea__inner {
background-color: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-textarea__inner::placeholder {
color: #6b7280;
}
/* Dark mode table */
html.dark .el-table {
background-color: #27293d;
color: #e5e7eb;
}
html.dark .el-table th.el-table__cell {
background-color: #1e1e2e;
color: #e5e7eb;
}
html.dark .el-table tr {
background-color: #27293d;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: #1e1e2e;
}
html.dark .el-table__row:hover > td.el-table__cell {
background-color: #2a2a3c !important;
}
/* Dark mode tags */
html.dark .el-tag--info {
background-color: #3a3d5c;
border-color: #3a3d5c;
color: #e5e7eb;
}
/* Dark mode buttons */
html.dark .el-button--default {
background-color: #27293d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background-color: #2a2a3c;
border-color: #4a4d6c;
color: #fff;
}
/* Dark mode select */
html.dark .el-select .el-input__wrapper {
background-color: #27293d;
}
html.dark .el-select-dropdown {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-select-dropdown__item {
color: #e5e7eb;
}
html.dark .el-select-dropdown__item:hover {
background-color: #2a2a3c;
}
/* Dark mode dialog */
html.dark .el-dialog {
background-color: #27293d;
}
html.dark .el-dialog__header {
border-bottom-color: #3a3d5c;
}
html.dark .el-dialog__title {
color: #e5e7eb;
}
html.dark .el-dialog__body {
color: #e5e7eb;
}
/* Dark mode message box */
html.dark .el-message-box {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-message-box__title {
color: #e5e7eb;
}
html.dark .el-message-box__message {
color: #e5e7eb;
}
/* Dark mode empty */
html.dark .el-empty__description {
color: #9ca3af;
}
/* Dark mode loading */
html.dark .el-loading-mask {
background-color: rgba(30, 30, 46, 0.9);
}
html.dark .el-loading-text {
color: #e5e7eb;
}
/* Dark mode tooltip */
html.dark .el-tooltip__trigger {
color: #e5e7eb;
}

View File

@@ -0,0 +1,5 @@
html.dark {
--el-bg-color: #343432;
--el-fill-color-blank: #343432;
background-color: #343432;
}

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 671 B

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
<path
d="M37 28 V72
M37 28 H63
M37 50 H55"
fill="none"
stroke="#FFFFFF"
stroke-width="14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1,102 @@
<template>
<div>
<el-row id="head">
<el-button type="primary" @click="fetchData">Refresh</el-button>
<el-button type="primary" @click="uploadConfig">Upload</el-button>
</el-row>
<el-input
type="textarea"
autosize
v-model="textarea"
placeholder="frpc configure file, can not be empty..."
></el-input>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
let textarea = ref('')
const fetchData = () => {
fetch('/api/config', { credentials: 'include' })
.then((res) => {
return res.text()
})
.then((text) => {
textarea.value = text
})
.catch(() => {
ElMessage({
showClose: true,
message: 'Get configure content from frpc failed!',
type: 'warning',
})
})
}
const uploadConfig = () => {
ElMessageBox.confirm(
'This operation will upload your frpc configure file content and hot reload it, do you want to continue?',
'Notice',
{
confirmButtonText: 'Yes',
cancelButtonText: 'No',
type: 'warning',
}
)
.then(() => {
if (textarea.value == '') {
ElMessage({
message: 'Configure content can not be empty!',
type: 'warning',
})
return
}
fetch('/api/config', {
credentials: 'include',
method: 'PUT',
body: textarea.value,
})
.then(() => {
fetch('/api/reload', { credentials: 'include' })
.then(() => {
ElMessage({
type: 'success',
message: 'Success',
})
})
.catch((err) => {
ElMessage({
showClose: true,
message: 'Reload frpc configure file error, ' + err,
type: 'warning',
})
})
})
.catch(() => {
ElMessage({
showClose: true,
message: 'Put config to frpc and hot reload failed!',
type: 'warning',
})
})
})
.catch(() => {
ElMessage({
message: 'Canceled',
type: 'info',
})
})
}
fetchData()
</script>
<style>
#head {
margin-bottom: 30px;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div>
<el-row>
<el-col :md="24">
<div>
<el-table
:data="status"
stripe
style="width: 100%"
:default-sort="{ prop: 'type', order: 'ascending' }"
>
<el-table-column
prop="name"
label="name"
sortable
></el-table-column>
<el-table-column
prop="type"
label="type"
width="150"
sortable
></el-table-column>
<el-table-column
prop="local_addr"
label="local address"
width="200"
sortable
></el-table-column>
<el-table-column
prop="plugin"
label="plugin"
width="200"
sortable
></el-table-column>
<el-table-column
prop="remote_addr"
label="remote address"
sortable
></el-table-column>
<el-table-column
prop="status"
label="status"
width="150"
sortable
></el-table-column>
<el-table-column prop="err" label="info"></el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
let status = ref<any[]>([])
const fetchData = () => {
fetch('/api/status', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
status.value = new Array()
for (let key in json) {
for (let ps of json[key]) {
console.log(ps)
status.value.push(ps)
}
}
})
.catch((err) => {
ElMessage({
showClose: true,
message: 'Get status info from frpc failed!' + err,
type: 'warning',
})
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,236 +0,0 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local:</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin:</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote:</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
</div>
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">{{ proxy.err }}</span>
</div>
<div class="status-badge" :class="statusClass">
{{ proxy.status }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Warning } from '@element-plus/icons-vue'
import type { ProxyStatus } from '../types/proxy'
interface Props {
proxy: ProxyStatus
}
const props = defineProps<Props>()
const statusClass = computed(() => {
switch (props.proxy.status) {
case 'running':
return 'running'
case 'error':
return 'error'
default:
return 'waiting'
}
})
</script>
<style scoped>
.proxy-card {
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.2s ease-in-out;
overflow: hidden;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.proxy-card.has-error {
border-color: var(--el-color-danger-light-5);
}
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
}
/* Left Section */
.card-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
}
.proxy-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.type-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.card-meta {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.error-info {
display: flex;
align-items: center;
gap: 6px;
max-width: 200px;
}
.error-icon {
color: var(--el-color-danger);
font-size: 16px;
flex-shrink: 0;
}
.error-text {
font-size: 12px;
color: var(--el-color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.error-info {
max-width: none;
flex: 1;
}
}
</style>

View File

@@ -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>

View File

@@ -1,10 +1,10 @@
import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './assets/css/custom.css'
import './assets/css/dark.css'
import './assets/dark.css'
const app = createApp(App)

View File

@@ -1,6 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Overview from '../views/Overview.vue'
import ClientConfigure from '../views/ClientConfigure.vue'
import Overview from '../components/Overview.vue'
import ClientConfigure from '../components/ClientConfigure.vue'
const router = createRouter({
history: createWebHashHistory(),

View File

@@ -1,5 +0,0 @@
declare module '*.svg?component' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@@ -1,12 +0,0 @@
export interface ProxyStatus {
name: string
type: string
status: string
err: string
local_addr: string
plugin: string
remote_addr: string
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>

View File

@@ -1,33 +0,0 @@
export function formatDistanceToNow(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
let interval = seconds / 31536000
if (interval > 1) return Math.floor(interval) + ' years ago'
interval = seconds / 2592000
if (interval > 1) return Math.floor(interval) + ' months ago'
interval = seconds / 86400
if (interval > 1) return Math.floor(interval) + ' days ago'
interval = seconds / 3600
if (interval > 1) return Math.floor(interval) + ' hours ago'
interval = seconds / 60
if (interval > 1) return Math.floor(interval) + ' minutes ago'
return Math.floor(seconds) + ' seconds ago'
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
// Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit
}

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