feat: support cloudflare workerd

This commit is contained in:
VaalaCat
2025-05-06 02:08:59 +00:00
parent 6949e1305a
commit 90f8884d1b
110 changed files with 8725 additions and 284 deletions

View File

@@ -0,0 +1,48 @@
name: Docker image with workerd
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: npm setup
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: "1.24.x"
- name: npm install and build
run: |
cd www
npm install && npm install -g pnpm
- name: Install dependencies
run: |
go mod tidy
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: Compile server
run: bash ./build.sh --current
- name: Setup ko
uses: ko-build/setup-ko@v0.9
env:
KO_DOCKER_REPO: docker.io/vaalacat/frp-panel
- name: Build image with ko
env:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
mv .ko.workerd.yaml .ko.yaml
echo "${password}" | ko login docker.io --username ${username} --password-stdin
ko build ./cmd/frpp --sbom=none --bare -t latest-workerd

16
.ko.workerd.yaml Normal file
View File

@@ -0,0 +1,16 @@
defaultBaseImage: jacoblincool/workerd
builds:
- id: frpp
dir: .
main: ./cmd/frpp
ldflags:
- -s -w
- -X github.com/VaalaCat/frp-panel/conf.buildDate={{.Date}}
- -X github.com/VaalaCat/frp-panel/conf.gitCommit={{.Git.FullCommit}}
- -X github.com/VaalaCat/frp-panel/conf.gitVersion={{.Git.Tag}}
- -X github.com/VaalaCat/frp-panel/conf.gitBranch={{.Git.Branch}}
defaultPlatforms:
- linux/arm64
- linux/amd64

View File

@@ -0,0 +1,31 @@
package client
import (
"fmt"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/workerd"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func CreateWorker(ctx *app.Context, req *pb.CreateWorkerRequest) (*pb.CreateWorkerResponse, error) {
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Errorf("function features are not enabled")
return nil, fmt.Errorf("function features are not enabled")
}
mgr := ctx.GetApp().GetWorkersManager()
ctrl := workerd.NewWorkerdController(req.GetWorker(), ctx.GetApp().GetConfig().Client.Worker.WorkerdWorkDir)
if err := mgr.RunWorker(ctx, req.GetWorker().GetWorkerId(), ctrl); err != nil {
return nil, err
}
logger.Logger(ctx).Infof("create worker success, id: [%s], running at: [%s]", req.GetWorker().GetWorkerId(), req.GetWorker().GetSocket().GetAddress())
return &pb.CreateWorkerResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
}

View File

@@ -0,0 +1,33 @@
package client
import (
"fmt"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func GetWorkerStatus(ctx *app.Context, req *pb.GetWorkerStatusRequest) (*pb.GetWorkerStatusResponse, error) {
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Errorf("function features are not enabled")
return nil, fmt.Errorf("function features are not enabled")
}
clientId := ctx.GetApp().GetConfig().Client.ID
workersMgr := ctx.GetApp().GetWorkersManager()
status, err := workersMgr.GetWorkerStatus(ctx, req.GetWorkerId())
if err != nil {
logger.Logger(ctx).Errorf("failed to get worker status: %v", err)
return nil, fmt.Errorf("failed to get worker status: %v", err)
}
logger.Logger(ctx).Infof("get worker status for worker [%s], status: [%s]", req.GetWorkerId(), status)
return &pb.GetWorkerStatusResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
WorkerStatus: map[string]string{
clientId: string(status),
},
}, nil
}

View File

@@ -0,0 +1,40 @@
package client
import (
"fmt"
"os"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func InstallWorkerd(ctx *app.Context, req *pb.InstallWorkerdRequest) (*pb.InstallWorkerdResponse, error) {
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Errorf("function features are not enabled")
return nil, fmt.Errorf("function features are not enabled")
}
workersMgr := ctx.GetApp().GetWorkersManager()
cwd, err := os.Getwd()
if err != nil {
logger.Logger(ctx).Errorf("failed to get current working directory: %v, will install workerd in /usr/local/bin", err)
}
binPath, err := workersMgr.InstallWorkerd(ctx, req.GetDownloadUrl(), cwd)
if err != nil {
logger.Logger(ctx).Errorf("failed to install workerd: %v", err)
return nil, fmt.Errorf("failed to install workerd: %v", err)
}
execMgr := ctx.GetApp().GetWorkerExecManager()
execMgr.UpdateBinaryPath(binPath)
return &pb.InstallWorkerdResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "ok",
},
}, nil
}

View File

@@ -0,0 +1,31 @@
package client
import (
"fmt"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func RemoveWorker(ctx *app.Context, req *pb.RemoveWorkerRequest) (*pb.RemoveWorkerResponse, error) {
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Errorf("function features are not enabled")
return nil, fmt.Errorf("function features are not enabled")
}
mgr := ctx.GetApp().GetWorkersManager()
workerId := req.GetWorkerId()
logger.Logger(ctx).Infof("start remove worker, id: [%s]", workerId)
if err := mgr.StopWorker(ctx, workerId); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot remove worker, id: [%s]", workerId)
return nil, err
}
logger.Logger(ctx).Infof("remove worker success, id: [%s]", workerId)
return &pb.RemoveWorkerResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
}

View File

@@ -19,7 +19,7 @@ func HandleServerMessage(appInstance app.Application, req *pb.ServerMessage) *pb
}
}()
c := context.Background()
logger.Logger(c).Infof("client get a server message, origin is: [%+v]", req)
logger.Logger(c).Infof("client get a server message, clientId: [%s], event: [%s], sessionId: [%s]", req.GetClientId(), req.GetEvent().String(), req.GetSessionId())
switch req.Event {
case pb.Event_EVENT_UPDATE_FRPC:
return app.WrapperServerMsg(appInstance, req, UpdateFrpcHander)
@@ -37,6 +37,14 @@ func HandleServerMessage(appInstance app.Application, req *pb.ServerMessage) *pb
return app.WrapperServerMsg(appInstance, req, StartPTYConnect)
case pb.Event_EVENT_GET_PROXY_INFO:
return app.WrapperServerMsg(appInstance, req, GetProxyConfig)
case pb.Event_EVENT_CREATE_WORKER:
return app.WrapperServerMsg(appInstance, req, CreateWorker)
case pb.Event_EVENT_REMOVE_WORKER:
return app.WrapperServerMsg(appInstance, req, RemoveWorker)
case pb.Event_EVENT_GET_WORKER_STATUS:
return app.WrapperServerMsg(appInstance, req, GetWorkerStatus)
case pb.Event_EVENT_INSTALL_WORKERD:
return app.WrapperServerMsg(appInstance, req, InstallWorkerd)
case pb.Event_EVENT_PING:
rawData, _ := proto.Marshal(conf.GetVersion().ToProto())
return &pb.ClientMessage{

View File

@@ -0,0 +1,48 @@
package client
import (
"context"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/workerd"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func PullWorkers(appInstance app.Application, clientID, clientSecret string) error {
ctx := app.NewContext(context.Background(), appInstance)
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Infof("function features are not enabled")
return nil
}
logger.Logger(ctx).Infof("start to pull workers belong to client, clientID: [%s]", clientID)
cli := ctx.GetApp().GetMasterCli()
resp, err := cli.Call().ListClientWorkers(ctx, &pb.ListClientWorkersRequest{
Base: &pb.ClientBase{
ClientId: clientID,
ClientSecret: clientSecret,
},
})
if err != nil {
logger.Logger(ctx).WithError(err).Error("cannot list client workers")
return err
}
if len(resp.GetWorkers()) == 0 {
logger.Logger(ctx).Infof("client [%s] has no workers", clientID)
return nil
}
ctrl := ctx.GetApp().GetWorkersManager()
for _, worker := range resp.GetWorkers() {
ctrl.RunWorker(ctx, worker.GetWorkerId(), workerd.NewWorkerdController(worker, ctx.GetApp().GetConfig().Client.Worker.WorkerdWorkDir))
}
logger.Logger(ctx).Infof("pull workers belong to client success, clientID: [%s], will run [%d] workers", clientID, len(resp.GetWorkers()))
return nil
}

View File

@@ -29,11 +29,13 @@ func StartFRPCHandler(ctx *app.Context, req *pb.StartFRPCRequest) (*pb.StartFRPC
}, nil
}
client, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientID)
cli, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientID)
if err != nil {
return nil, err
}
client := cli.ClientEntity
client.Stopped = false
if err = dao.NewQuery(ctx).UpdateClient(userInfo, client); err != nil {

View File

@@ -29,11 +29,13 @@ func StopFRPCHandler(ctx *app.Context, req *pb.StopFRPCRequest) (*pb.StopFRPCRes
}, nil
}
client, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientID)
cli, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientID)
if err != nil {
return nil, err
}
client := cli.ClientEntity
client.Stopped = true
if err = dao.NewQuery(ctx).UpdateClient(userInfo, client); err != nil {

View File

@@ -36,7 +36,7 @@ func UpdateFrpcHander(c *app.Context, req *pb.UpdateFRPCRequest) (*pb.UpdateFRPC
}, err
}
cli, err := dao.NewQuery(c).GetClientByClientID(userInfo, reqClientID)
cliRecord, err := dao.NewQuery(c).GetClientByClientID(userInfo, reqClientID)
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot get client, id: [%s]", reqClientID)
return &pb.UpdateFRPCResponse{
@@ -44,6 +44,8 @@ func UpdateFrpcHander(c *app.Context, req *pb.UpdateFRPCRequest) (*pb.UpdateFRPC
}, fmt.Errorf("cannot get client")
}
cli := cliRecord.ClientEntity
if cli.IsShadow {
cli, err = ChildClientForServer(c, serverID, cli)
if err != nil {

View File

@@ -11,6 +11,7 @@ import (
"github.com/VaalaCat/frp-panel/biz/master/shell"
"github.com/VaalaCat/frp-panel/biz/master/streamlog"
"github.com/VaalaCat/frp-panel/biz/master/user"
"github.com/VaalaCat/frp-panel/biz/master/worker"
"github.com/VaalaCat/frp-panel/middleware"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/gin-gonic/gin"
@@ -51,6 +52,7 @@ func ConfigureRouter(appInstance app.Application, router *gin.Engine) {
clientRouter.POST("/init", app.Wrapper(appInstance, client.InitClientHandler))
clientRouter.POST("/delete", app.Wrapper(appInstance, client.DeleteClientHandler))
clientRouter.POST("/list", app.Wrapper(appInstance, client.ListClientsHandler))
clientRouter.POST("/install_workerd", app.Wrapper(appInstance, worker.InstallWorkerd))
}
serverRouter := v1.Group("/server")
{
@@ -83,6 +85,17 @@ func ConfigureRouter(appInstance app.Application, router *gin.Engine) {
proxyRouter.POST("/start_proxy", app.Wrapper(appInstance, proxy.StartProxy))
proxyRouter.POST("/stop_proxy", app.Wrapper(appInstance, proxy.StopProxy))
}
workerHandler := v1.Group("/worker")
{
workerHandler.POST("/get", app.Wrapper(appInstance, worker.GetWorker))
workerHandler.POST("/status", app.Wrapper(appInstance, worker.GetWorkerStatus))
workerHandler.POST("/create", app.Wrapper(appInstance, worker.CreateWorker))
workerHandler.POST("/list", app.Wrapper(appInstance, worker.ListWorkers))
workerHandler.POST("/remove", app.Wrapper(appInstance, worker.RemoveWorker))
workerHandler.POST("/update", app.Wrapper(appInstance, worker.UpdateWorker))
workerHandler.POST("/create_ingress", app.Wrapper(appInstance, worker.CreateWorkerIngress))
workerHandler.POST("/get_ingress", app.Wrapper(appInstance, worker.GetWorkerIngress))
}
v1.GET("/pty/:clientID", shell.PTYHandler(appInstance))
v1.GET("/log", streamlog.GetLogHandler(appInstance))
}

View File

@@ -30,7 +30,7 @@ func CreateProxyConfig(c *app.Context, req *pb.CreateProxyConfigRequest) (*pb.Cr
serverID = req.GetServerId()
)
clientEntity, err := getClientWithMakeShadow(c, clientID, serverID)
clientEntity, err := GetClientWithMakeShadow(c, clientID, serverID)
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot get client, id: [%s]", clientID)
return nil, err
@@ -42,13 +42,6 @@ func CreateProxyConfig(c *app.Context, req *pb.CreateProxyConfigRequest) (*pb.Cr
return nil, err
}
proxyCfg := &models.ProxyConfigEntity{}
if err := proxyCfg.FillClientConfig(clientEntity); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot fill client config, id: [%s]", clientID)
return nil, err
}
typedProxyCfgs, err := utils.LoadProxiesFromContent(req.GetConfig())
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot load proxies from content")
@@ -59,43 +52,86 @@ func CreateProxyConfig(c *app.Context, req *pb.CreateProxyConfigRequest) (*pb.Cr
return nil, fmt.Errorf("invalid config")
}
if err := proxyCfg.FillTypedProxyConfig(typedProxyCfgs[0]); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot fill typed proxy config")
if err := CreateProxyConfigWithTypedConfig(c, CreateProxyConfigWithTypedConfigParam{
ClientID: clientID,
ServerID: serverID,
ProxyCfg: typedProxyCfgs[0],
ClientEntity: clientEntity,
Overwrite: req.GetOverwrite(),
}); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot create proxy config")
return nil, err
}
return &pb.CreateProxyConfigResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
}
type CreateProxyConfigWithTypedConfigParam struct {
ClientID string
ServerID string
ProxyCfg v1.TypedProxyConfig
ClientEntity *models.ClientEntity
Overwrite bool
WorkerID *string
}
func CreateProxyConfigWithTypedConfig(c *app.Context, param CreateProxyConfigWithTypedConfigParam) error {
var (
userInfo = common.GetUserInfo(c)
clientID = param.ClientID
serverID = param.ServerID
clientEntity = param.ClientEntity
typedProxyCfg = param.ProxyCfg
err error
overwrite = param.Overwrite
)
proxyCfg := &models.ProxyConfigEntity{}
if err := proxyCfg.FillClientConfig(clientEntity); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot fill client config, id: [%s]", clientID)
return err
}
if err := proxyCfg.FillTypedProxyConfig(typedProxyCfg); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot fill typed proxy config")
return err
}
var existedProxyCfg *models.ProxyConfig
existedProxyCfg, err = dao.NewQuery(c).GetProxyConfigByOriginClientIDAndName(userInfo, clientID, proxyCfg.Name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logger.Logger(c).WithError(err).Errorf("cannot get proxy config, id: [%s]", clientID)
return nil, err
return err
}
if !req.GetOverwrite() && err == nil {
if !overwrite && err == nil {
logger.Logger(c).Errorf("proxy config already exist, cfg: [%+v]", proxyCfg)
return nil, fmt.Errorf("proxy config already exist")
return fmt.Errorf("proxy config already exist")
}
// update client config
if oldCfg, err := clientEntity.GetConfigContent(); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot get client config, id: [%s]", clientID)
return nil, err
return err
} else {
oldCfg.Proxies = lo.Filter(oldCfg.Proxies, func(proxy v1.TypedProxyConfig, _ int) bool {
return proxy.GetBaseConfig().Name != typedProxyCfgs[0].GetBaseConfig().Name
return proxy.GetBaseConfig().Name != typedProxyCfg.GetBaseConfig().Name
})
oldCfg.Proxies = append(oldCfg.Proxies, typedProxyCfgs...)
oldCfg.Proxies = append(oldCfg.Proxies, typedProxyCfg)
if err := clientEntity.SetConfigContent(*oldCfg); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot set client config, id: [%s]", clientID)
return nil, err
return err
}
}
rawCfg, err := clientEntity.MarshalJSONConfig()
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot marshal client config, id: [%s]", clientID)
return nil, err
return err
}
_, err = client.UpdateFrpcHander(c, &pb.UpdateFRPCRequest{
@@ -117,11 +153,9 @@ func CreateProxyConfig(c *app.Context, req *pb.CreateProxyConfigRequest) (*pb.Cr
Name: &proxyCfg.Name,
}); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot delete old proxy, client: [%s], server: [%s], proxy: [%s]", clientID, clientEntity.ServerID, proxyCfg.Name)
return nil, err
return err
}
}
return &pb.CreateProxyConfigResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
return nil
}

View File

@@ -49,7 +49,7 @@ func DeleteProxyConfig(c *app.Context, req *pb.DeleteProxyConfigRequest) (*pb.De
return nil, err
}
if err := dao.NewQuery(c).UpdateClient(userInfo, cli); err != nil {
if err := dao.NewQuery(c).UpdateClient(userInfo, cli.ClientEntity); err != nil {
logger.Logger(c).WithError(err).Errorf("cannot update client, id: [%s]", clientID)
return nil, err
}

View File

@@ -29,11 +29,11 @@ func convertProxyStatsList(proxyList []*models.ProxyStatsEntity) []*pb.ProxyInfo
})
}
// getClientWithMakeShadow
// GetClientWithMakeShadow
// 1. 检查是否有已连接该服务端的客户端
// 2. 检查是否有Shadow客户端
// 3. 如果没有则新建Shadow客户端和子客户端
func getClientWithMakeShadow(c *app.Context, clientID, serverID string) (*models.ClientEntity, error) {
func GetClientWithMakeShadow(c *app.Context, clientID, serverID string) (*models.ClientEntity, error) {
userInfo := common.GetUserInfo(c)
clientEntity, err := dao.NewQuery(c).GetClientByFilter(userInfo, &models.ClientEntity{OriginClientID: clientID, ServerID: serverID}, lo.ToPtr(false))
if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -21,7 +21,7 @@ func StartProxy(ctx *app.Context, req *pb.StartProxyRequest) (*pb.StartProxyResp
proxyName = req.GetName()
)
clientEntity, err := getClientWithMakeShadow(ctx, clientID, serverID)
clientEntity, err := GetClientWithMakeShadow(ctx, clientID, serverID)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get client, id: [%s]", clientID)
return nil, err

View File

@@ -20,7 +20,7 @@ func StopProxy(ctx *app.Context, req *pb.StopProxyRequest) (*pb.StopProxyRespons
proxyName = req.GetName()
)
clientEntity, err := getClientWithMakeShadow(ctx, clientID, serverID)
clientEntity, err := GetClientWithMakeShadow(ctx, clientID, serverID)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get client, id: [%s]", clientID)
return nil, err

View File

@@ -26,12 +26,14 @@ func UpdateProxyConfig(c *app.Context, req *pb.UpdateProxyConfigRequest) (*pb.Up
serverID = req.GetServerId()
)
clientEntity, err := dao.NewQuery(c).GetClientByClientID(userInfo, clientID)
cli, err := dao.NewQuery(c).GetClientByClientID(userInfo, clientID)
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot get client, id: [%s]", clientID)
return nil, err
}
clientEntity := cli.ClientEntity
if clientEntity.ServerID != serverID {
logger.Logger(c).Errorf("client and server not match, find or create client, client: [%s], server: [%s]", clientID, serverID)
originClient, err := dao.NewQuery(c).GetClientByClientID(userInfo, clientEntity.OriginClientID)
@@ -40,7 +42,7 @@ func UpdateProxyConfig(c *app.Context, req *pb.UpdateProxyConfigRequest) (*pb.Up
return nil, err
}
clientEntity, err = client.ChildClientForServer(c, serverID, originClient)
clientEntity, err = client.ChildClientForServer(c, serverID, originClient.ClientEntity)
if err != nil {
logger.Logger(c).WithError(err).Errorf("cannot create child client, id: [%s]", clientID)
return nil, err

View File

@@ -0,0 +1,72 @@
package worker
import (
"fmt"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/services/workerd"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func CreateWorker(ctx *app.Context, req *pb.CreateWorkerRequest) (*pb.CreateWorkerResponse, error) {
var (
userInfo = common.GetUserInfo(ctx)
clientId = req.GetClientId()
reqWorker = req.GetWorker()
)
if err := validateCreateWorker(req); err != nil {
logger.Logger(ctx).WithError(err).Errorf("invalid create worker request, origin is: [%s]", req.String())
return nil, err
}
cli, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get client, id: [%s], workerName: [%s]", clientId, reqWorker.GetName())
return nil, err
}
workerd.FillWorkerValue(reqWorker, uint(userInfo.GetUserID()))
workerToCreate := (&models.Worker{}).FromPB(reqWorker)
workerToCreate.WorkerModel = nil
workerToCreate.Clients = append(workerToCreate.Clients, *cli)
if err := dao.NewQuery(ctx).CreateWorker(userInfo, workerToCreate); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot create worker, workerName: [%s]", workerToCreate.Name)
return nil, err
}
go func() {
bgCtx := ctx.Background()
resp := &pb.CreateWorkerResponse{}
err := rpc.CallClientWrapper(bgCtx, clientId, pb.Event_EVENT_CREATE_WORKER, req, resp)
if err != nil {
logger.Logger(bgCtx).WithError(err).Errorf("create worker event send to client error, client id: [%s], worker name: [%s]", clientId, workerToCreate.Name)
}
}()
logger.Logger(ctx).Infof("create worker success, workerName: [%s], start to create worker's proxy", workerToCreate.Name)
return &pb.CreateWorkerResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
WorkerId: &workerToCreate.ID,
}, nil
}
func validateCreateWorker(req *pb.CreateWorkerRequest) error {
if len(req.GetClientId()) == 0 {
return fmt.Errorf("invalid client id")
}
if req.GetWorker() == nil {
return fmt.Errorf("invalid worker")
}
return nil
}

View File

@@ -0,0 +1,106 @@
package worker
import (
"fmt"
"strings"
"github.com/VaalaCat/frp-panel/biz/master/proxy"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/utils/logger"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func IngressName(worker *models.Worker, cli *models.ClientEntity) string {
return fmt.Sprintf("ingress-%s-%s", strings.Split(worker.ID, "-")[0], cli.OriginClientID)
}
func CreateWorkerIngress(ctx *app.Context, req *pb.CreateWorkerIngressRequest) (*pb.CreateWorkerIngressResponse, error) {
if err := validateCreateWorkerIngressRequest(req); err != nil {
logger.Logger(ctx).WithError(err).Errorf("invalid create worker ingress request, origin is: [%s]", req.String())
return nil, err
}
var (
clientId = req.GetClientId()
serverId = req.GetServerId()
workerId = req.GetWorkerId()
userInfo = common.GetUserInfo(ctx)
)
clientEntity, err := proxy.GetClientWithMakeShadow(ctx, clientId, serverId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get client, id: [%s]", clientId)
return nil, err
}
_, err = dao.NewQuery(ctx).GetServerByServerID(userInfo, serverId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get server, id: [%s]", serverId)
return nil, err
}
workerToExpose, err := dao.NewQuery(ctx).GetWorkerByWorkerID(userInfo, workerId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get worker, id: [%s]", workerId)
return nil, err
}
httpProxyCfg := v1.HTTPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: IngressName(workerToExpose, clientEntity),
Type: string(v1.ProxyTypeHTTP),
Annotations: map[string]string{
defs.FrpProxyAnnotationsKey_Ingress: "true",
defs.FrpProxyAnnotationsKey_WorkerId: workerId,
},
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginUnixDomainSocket,
ClientPluginOptions: &v1.UnixDomainSocketPluginOptions{
Type: v1.PluginUnixDomainSocket,
UnixPath: fmt.Sprintf("@%s", strings.TrimPrefix(workerToExpose.Socket.Data.GetAddress(), "unix-abstract:")),
},
},
},
},
DomainConfig: v1.DomainConfig{
SubDomain: workerId,
},
}
if err := proxy.CreateProxyConfigWithTypedConfig(ctx, proxy.CreateProxyConfigWithTypedConfigParam{
ClientID: clientId,
ServerID: serverId,
ProxyCfg: v1.TypedProxyConfig{
Type: string(v1.ProxyTypeHTTP),
ProxyConfigurer: &httpProxyCfg,
},
ClientEntity: clientEntity,
Overwrite: true,
}); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot create proxy config, client id: [%s], server id: [%s], worker id: [%s]", clientId, serverId, workerId)
return nil, err
}
return &pb.CreateWorkerIngressResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
}
func validateCreateWorkerIngressRequest(req *pb.CreateWorkerIngressRequest) error {
if req == nil {
return fmt.Errorf("invalid request")
}
if len(req.GetClientId()) == 0 || len(req.GetServerId()) == 0 || len(req.GetWorkerId()) == 0 {
return fmt.Errorf("invalid request, client id: [%s], server id: [%s], worker id: [%s]", req.GetClientId(), req.GetServerId(), req.GetWorkerId())
}
return nil
}

View File

@@ -0,0 +1,46 @@
package worker
import (
"fmt"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
)
func GetWorker(ctx *app.Context, req *pb.GetWorkerRequest) (*pb.GetWorkerResponse, error) {
logger.Logger(ctx).Infof("get worker req: %s", req.String())
var (
workerID = req.GetWorkerId()
userInfo = common.GetUserInfo(ctx)
)
if len(workerID) == 0 {
logger.Logger(ctx).Errorf("worker id is empty")
return nil, fmt.Errorf("worker id is empty")
}
workerRecord, err := dao.NewQuery(ctx).GetWorkerByWorkerID(userInfo, workerID)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("get worker by id failed")
return nil, err
}
return &pb.GetWorkerResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "ok",
},
Worker: workerRecord.ToPB(),
Clients: lo.Map(workerRecord.Clients, func(client models.Client, index int) *pb.Client {
c := client.ToPB()
c.Config = nil
c.Secret = nil
return c
}),
}, nil
}

View File

@@ -0,0 +1,32 @@
package worker
import (
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
)
func GetWorkerIngress(ctx *app.Context, req *pb.GetWorkerIngressRequest) (*pb.GetWorkerIngressResponse, error) {
logger.Logger(ctx).Infof("get worker: [%s] ingress", req.GetWorkerId())
var (
workerId = req.GetWorkerId()
userInfo = common.GetUserInfo(ctx)
)
proxyCfgs, err := dao.NewQuery(ctx).GetProxyConfigsByWorkerId(userInfo, workerId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("failed to get proxy configs for worker: [%s]", workerId)
return nil, err
}
logger.Logger(ctx).Infof("got proxy configs for worker: [%s] success, ingresses length: [%d]", workerId, len(proxyCfgs))
return &pb.GetWorkerIngressResponse{
ProxyConfigs: lo.Map(proxyCfgs, func(item *models.ProxyConfig, index int) *pb.ProxyConfig {
return item.ToPB()
}),
}, nil
}

View File

@@ -0,0 +1,65 @@
package worker
import (
"fmt"
"maps"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
"github.com/sourcegraph/conc/pool"
)
func GetWorkerStatus(ctx *app.Context, req *pb.GetWorkerStatusRequest) (*pb.GetWorkerStatusResponse, error) {
var (
workerID = req.GetWorkerId()
userInfo = common.GetUserInfo(ctx)
)
if len(workerID) == 0 {
logger.Logger(ctx).Errorf("worker id is empty")
return nil, fmt.Errorf("worker id is empty")
}
workerRecord, err := dao.NewQuery(ctx).GetWorkerByWorkerID(userInfo, workerID)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("get worker by id failed")
return nil, err
}
clientIds := lo.Map(workerRecord.Clients, func(cli models.Client, _ int) string {
return cli.ClientID
})
var pool pool.ResultErrorPool[*pb.GetWorkerStatusResponse]
for _, clientID := range clientIds {
pool.Go(func() (*pb.GetWorkerStatusResponse, error) {
bgCtx := ctx.Background()
cliResp := &pb.GetWorkerStatusResponse{}
err := rpc.CallClientWrapper(bgCtx, clientID, pb.Event_EVENT_GET_WORKER_STATUS, &pb.GetWorkerStatusRequest{}, cliResp)
return cliResp, err
})
}
resps, err := pool.Wait()
if err != nil {
logger.Logger(ctx).WithError(err).Warnf("get worker status failed")
}
statusMap := map[string]string{}
for _, r := range resps {
s := r.GetWorkerStatus()
maps.Copy(statusMap, s)
}
return &pb.GetWorkerStatusResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
WorkerStatus: statusMap,
}, nil
}

View File

@@ -0,0 +1,38 @@
package worker
import (
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/utils/logger"
)
func InstallWorkerd(ctx *app.Context, req *pb.InstallWorkerdRequest) (*pb.InstallWorkerdResponse, error) {
var (
userInfo = common.GetUserInfo(ctx)
clientId = req.GetClientId()
)
logger.Logger(ctx).Infof("installw orkerd called with userInfo: %v, clientId: %s", userInfo, clientId)
_, err := dao.NewQuery(ctx).GetClientByClientID(userInfo, clientId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("failed to get client by clientID: %s", clientId)
return nil, err
}
resp := &pb.InstallWorkerdResponse{}
if err := rpc.CallClientWrapper(ctx, clientId, pb.Event_EVENT_INSTALL_WORKERD, req, resp); err != nil {
logger.Logger(ctx).WithError(err).Errorf("failed to call install workerd with clientId: %s", clientId)
return nil, err
}
logger.Logger(ctx).Infof("install workerd success with clientId: %s", clientId)
return &pb.InstallWorkerdResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "ok",
},
}, nil
}

View File

@@ -0,0 +1,100 @@
package worker
import (
"fmt"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
)
func ListWorkers(ctx *app.Context, req *pb.ListWorkersRequest) (*pb.ListWorkersResponse, error) {
var (
userInfo = common.GetUserInfo(ctx)
page = int(req.GetPage())
pageSize = int(req.GetPageSize())
keyword = req.GetKeyword()
err error
workers []*models.Worker
workerCounts int64
hasKeyword = len(keyword) > 0
)
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = 10
}
if hasKeyword {
workers, err = dao.NewQuery(ctx).ListWorkersWithKeyword(userInfo, page, pageSize, keyword)
} else {
workers, err = dao.NewQuery(ctx).ListWorkers(userInfo, page, pageSize)
}
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot list workers, page: [%d], pageSize: [%d], keyword: [%s]", page, pageSize, keyword)
return &pb.ListWorkersResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_NOT_FOUND, Message: err.Error()},
}, fmt.Errorf("cannot list workers, page: [%d], pageSize: [%d], keyword: [%s]", page, pageSize, keyword)
}
if hasKeyword {
workerCounts, err = dao.NewQuery(ctx).CountWorkersWithKeyword(userInfo, keyword)
} else {
workerCounts, err = dao.NewQuery(ctx).CountWorkers(userInfo)
}
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot count workers, keyword: [%s]", keyword)
return &pb.ListWorkersResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_NOT_FOUND, Message: err.Error()},
}, fmt.Errorf("cannot count workers, keyword: [%s]", keyword)
}
return &pb.ListWorkersResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "success",
},
Total: lo.ToPtr(int32(workerCounts)),
Workers: lo.Map(workers, func(w *models.Worker, _ int) *pb.Worker {
k := w.ToPB()
k.Code = nil
k.ConfigTemplate = nil
return k
}),
}, nil
}
func ListClientWorkers(ctx *app.Context, req *pb.ListClientWorkersRequest) (*pb.ListClientWorkersResponse, error) {
var (
err error
workers []*models.Worker
clientId = req.GetBase().GetClientId()
)
workers, err = dao.NewQuery(ctx).AdminListWorkersByClientID(clientId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot list workers, clientId: [%s]", clientId)
return &pb.ListClientWorkersResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_NOT_FOUND, Message: err.Error()},
}, fmt.Errorf("cannot list workers, clientId: [%s]", clientId)
}
logger.Logger(ctx).Infof("list workers, clientId: [%s], worker len: [%d]", clientId, len(workers))
return &pb.ListClientWorkersResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "success",
},
Workers: lo.Map(workers, func(w *models.Worker, _ int) *pb.Worker {
k := w.ToPB()
return k
}),
}, nil
}

View File

@@ -0,0 +1,75 @@
package worker
import (
"github.com/VaalaCat/frp-panel/biz/master/proxy"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
)
func RemoveWorker(ctx *app.Context, req *pb.RemoveWorkerRequest) (*pb.RemoveWorkerResponse, error) {
var (
userInfo = common.GetUserInfo(ctx)
workerId = req.GetWorkerId()
)
logger.Logger(ctx).Infof("start remove worker, id: [%s]", workerId)
workerToDelete, err := dao.NewQuery(ctx).GetWorkerByWorkerID(userInfo, workerId)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get worker, id: [%s]", workerId)
return nil, err
}
if ingressesToDelete, err := dao.NewQuery(ctx).GetProxyConfigsByWorkerId(userInfo, workerId); err == nil {
for _, ingressToDelete := range ingressesToDelete {
logger.Logger(ctx).Infof("start to remove worker ingress on server: [%s] client: [%s], name: [%s]", ingressToDelete.ServerID, ingressToDelete.ClientID, ingressToDelete.Name)
resp, err := proxy.DeleteProxyConfig(ctx, &pb.DeleteProxyConfigRequest{
ServerId: lo.ToPtr(ingressToDelete.ServerID),
ClientId: lo.ToPtr(ingressToDelete.ClientID),
Name: lo.ToPtr(ingressToDelete.Name),
})
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot remove worker ingress on server: [%s] client: [%s], name: [%s], resp is: [%s]",
ingressToDelete.ServerID, ingressToDelete.ClientID, ingressToDelete.Name, resp.String())
return nil, err
}
logger.Logger(ctx).Infof("remove worker ingress success on server: [%s] client: [%s], name: [%s]", ingressToDelete.ServerID, ingressToDelete.ClientID, ingressToDelete.Name)
}
}
if err := dao.NewQuery(ctx).DeleteWorker(userInfo, workerId); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot remove worker, id: [%s]", workerId)
return nil, err
}
go func() {
bgCtx := ctx.Background()
hasErr := false
for _, cli := range workerToDelete.Clients {
resp := &pb.RemoveWorkerResponse{}
if err := rpc.CallClientWrapper(bgCtx, cli.ClientID, pb.Event_EVENT_REMOVE_WORKER, req, resp); err != nil {
logger.Logger(bgCtx).WithError(err).Errorf("remove event send to client error, client id: [%s]", cli.ClientID)
hasErr = true
continue
}
}
if hasErr {
logger.Logger(bgCtx).Errorf("remove event send to client error")
}
}()
logger.Logger(ctx).Infof("remove worker success, id: [%s]", workerId)
return &pb.RemoveWorkerResponse{
Status: &pb.Status{Code: pb.RespCode_RESP_CODE_SUCCESS, Message: "ok"},
}, nil
}

View File

@@ -0,0 +1 @@
package worker

View File

@@ -0,0 +1 @@
package worker

View File

@@ -0,0 +1,104 @@
package worker
import (
"fmt"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/models"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/services/dao"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
)
func UpdateWorker(ctx *app.Context, req *pb.UpdateWorkerRequest) (*pb.UpdateWorkerResponse, error) {
var (
clientIds = req.GetClientIds()
wrokerReq = req.GetWorker()
userInfo = common.GetUserInfo(ctx)
oldClientIds []string
)
workerToUpdate, err := dao.NewQuery(ctx).GetWorkerByWorkerID(userInfo, wrokerReq.GetWorkerId())
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get worker, id: [%s]", wrokerReq.GetWorkerId())
return nil, fmt.Errorf("cannot get worker, id: [%s]", wrokerReq.GetWorkerId())
}
clis, err := dao.NewQuery(ctx).GetClientsByClientIDs(userInfo, clientIds)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot get client, id: [%s]", utils.MarshalForJson(clientIds))
return nil, fmt.Errorf("cannot get client, id: [%s]", utils.MarshalForJson(clientIds))
}
updatedFields := []string{}
if len(clientIds) != 0 {
oldClientIds = lo.Map(workerToUpdate.Clients, func(c models.Client, _ int) string { return c.ClientID })
workerToUpdate.Clients = lo.Map(clis, func(c *models.Client, _ int) models.Client { return *c })
updatedFields = append(updatedFields, "client_id")
} else {
oldClientIds = lo.Map(workerToUpdate.Clients, func(c models.Client, _ int) string { return c.ClientID })
}
if len(wrokerReq.GetName()) != 0 {
workerToUpdate.Name = wrokerReq.GetName()
updatedFields = append(updatedFields, "name")
}
if len(wrokerReq.GetCode()) != 0 {
workerToUpdate.Code = wrokerReq.GetCode()
updatedFields = append(updatedFields, "code")
}
if len(wrokerReq.GetConfigTemplate()) != 0 {
workerToUpdate.ConfigTemplate = wrokerReq.GetConfigTemplate()
updatedFields = append(updatedFields, "config_template")
}
if err := dao.NewQuery(ctx).UpdateWorker(userInfo, workerToUpdate); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot update worker, id: [%s]", wrokerReq.GetWorkerId())
return nil, fmt.Errorf("cannot update worker, id: [%s]", wrokerReq.GetWorkerId())
}
go func() {
bgCtx := ctx.Background()
for _, oldClientId := range oldClientIds {
removeResp := &pb.RemoveWorkerResponse{}
err := rpc.CallClientWrapper(bgCtx, oldClientId, pb.Event_EVENT_REMOVE_WORKER, &pb.RemoveWorkerRequest{
ClientId: &oldClientId,
WorkerId: &workerToUpdate.ID,
}, removeResp)
if err != nil {
logger.Logger(bgCtx).WithError(err).Errorf("remove old worker event send to client error, clients: [%s], worker name: [%s]", oldClientId, workerToUpdate.Name)
}
}
for _, newClient := range clis {
createResp := &pb.CreateWorkerResponse{}
err = rpc.CallClientWrapper(bgCtx, newClient.ClientID, pb.Event_EVENT_CREATE_WORKER, &pb.CreateWorkerRequest{
ClientId: &newClient.ClientID,
Worker: workerToUpdate.ToPB(),
}, createResp)
if err != nil {
logger.Logger(bgCtx).WithError(err).Errorf("update new worker event send to client error, client id: [%s], worker name: [%s]", newClient.ClientID, workerToUpdate.Name)
}
}
logger.Logger(ctx).Infof("update worker event send to client success, clients: [%s], worker name: [%s], remove old worker send to those clients: %s",
utils.MarshalForJson(clis), workerToUpdate.Name, utils.MarshalForJson(oldClientIds))
}()
logger.Logger(ctx).Infof("update worker success, id: [%s], updated fields: %s", wrokerReq.GetWorkerId(), utils.MarshalForJson(updatedFields))
return &pb.UpdateWorkerResponse{
Status: &pb.Status{
Code: pb.RespCode_RESP_CODE_SUCCESS,
Message: "ok",
},
}, nil
}

View File

@@ -24,6 +24,7 @@ type runClientParam struct {
Ctx *app.Context
AppInstance app.Application
TaskManager watcher.Client `name:"clientTaskManager"`
WorkersManager app.WorkersManager
Cfg conf.Config
}
@@ -45,6 +46,8 @@ func runClient(param runClientParam) {
param.TaskManager.AddDurationTask(defs.PullConfigDuration,
bizclient.PullConfig, appInstance, clientID, clientSecret)
param.TaskManager.AddDurationTask(defs.PullClientWorkersDuration,
bizclient.PullWorkers, appInstance, clientID, clientSecret)
var wg conc.WaitGroup
param.Lc.Append(fx.Hook{
@@ -62,7 +65,10 @@ func runClient(param runClientParam) {
)
appInstance.SetClientRPCHandler(cliRpcHandler)
// --- init once start ---
initClientOnce(appInstance, clientID, clientSecret)
initClientWorkerOnce(appInstance, clientID, clientSecret)
// --- init once stop ----
wg.Go(cliRpcHandler.Run)
wg.Go(param.TaskManager.Run)
@@ -84,3 +90,10 @@ func initClientOnce(appInstance app.Application, clientID, clientSecret string)
logger.Logger(context.Background()).WithError(err).Errorf("cannot pull client config, wait for retry")
}
}
func initClientWorkerOnce(appInstance app.Application, clientID, clientSecret string) {
err := bizclient.PullWorkers(appInstance, clientID, clientSecret)
if err != nil {
logger.Logger(context.Background()).WithError(err).Errorf("cannot pull client workers, wait for retry")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/joho/godotenv"
"github.com/samber/lo"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.uber.org/fx"
@@ -383,13 +384,13 @@ func patchConfig(appInstance app.Application, commonArgs CommonArgs) conf.Config
tmpCfg.Client.RPCUrl = *commonArgs.RpcUrl
}
if commonArgs.RpcPort != nil || commonArgs.ApiPort != nil ||
commonArgs.ApiScheme != nil ||
commonArgs.RpcHost != nil || commonArgs.ApiHost != nil {
logger.Logger(c).Warnf("deprecatedenv configs !!! pls use api url and rpc url \n\n rpc host: %s, rpc port: %d, api host: %s, api port: %d, api scheme: %s",
if lo.FromPtrOr(commonArgs.RpcPort, 0) != 0 || lo.FromPtrOr(commonArgs.ApiPort, 0) != 0 ||
lo.FromPtrOr(commonArgs.ApiScheme, "") != "" ||
lo.FromPtrOr(commonArgs.RpcHost, "") != "" || lo.FromPtrOr(commonArgs.ApiHost, "") != "" {
logger.Logger(c).Warnf("deprecatedenv configs !!! pls use api url and rpc url \n\n rpc host: %s, rpc port: %d, api host: %s, api port: %d, api scheme: %s, \n\n args: %s",
tmpCfg.Master.RPCHost, tmpCfg.Master.RPCPort,
tmpCfg.Master.APIHost, tmpCfg.Master.APIPort,
tmpCfg.Master.APIScheme)
tmpCfg.Master.APIScheme, utils.MarshalForJson(tmpCfg))
} else if len(tmpCfg.Client.APIUrl) > 0 || len(tmpCfg.Client.RPCUrl) > 0 {
logger.Logger(c).Infof("env config, api url: %s, rpc url: %s", tmpCfg.Client.APIUrl, tmpCfg.Client.RPCUrl)
}

View File

@@ -7,6 +7,8 @@ import (
var (
clientMod = fx.Module("cmd.client",
fx.Provide(
NewWorkerExecManager,
NewWorkersManager,
fx.Annotate(NewWatcher, fx.ResultTags(`name:"clientTaskManager"`)),
))

View File

@@ -6,6 +6,7 @@ import (
"embed"
"net"
"net/http"
"os"
"path/filepath"
"sync"
@@ -26,6 +27,7 @@ import (
"github.com/VaalaCat/frp-panel/services/rbac"
"github.com/VaalaCat/frp-panel/services/rpc"
"github.com/VaalaCat/frp-panel/services/watcher"
"github.com/VaalaCat/frp-panel/services/workerd"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/VaalaCat/frp-panel/utils/wsgrpc"
@@ -373,3 +375,39 @@ func NewEnforcer(param struct {
param.AppInstance.SetEnforcer(e)
return e
}
func NewWorkersManager(lx fx.Lifecycle, mgr app.WorkerExecManager, appInstance app.Application) app.WorkersManager {
if !appInstance.GetConfig().Client.Features.EnableFunctions {
return nil
}
workerMgr := workerd.NewWorkersManager()
appInstance.SetWorkersManager(workerMgr)
lx.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
workerMgr.StopAll()
logger.Logger(ctx).Info("stop all workers")
return nil
},
})
return workerMgr
}
func NewWorkerExecManager(cfg conf.Config, appInstance app.Application) app.WorkerExecManager {
if !appInstance.GetConfig().Client.Features.EnableFunctions {
return nil
}
workerdBinPath := cfg.Client.Worker.WorkerdBinaryPath
if err := os.MkdirAll(cfg.Client.Worker.WorkerdWorkDir, os.ModePerm); err != nil {
logger.Logger(context.Background()).WithError(err).Fatalf("create work dir failed, path: [%s]", cfg.Client.Worker.WorkerdWorkDir)
}
mgr := workerd.NewExecManager(workerdBinPath,
[]string{"serve", "--watch", "--verbose"})
appInstance.SetWorkerExecManager(mgr)
return mgr
}

View File

@@ -25,7 +25,10 @@ type ReqType interface {
pb.GetProxyStatsByClientIDRequest | pb.GetProxyStatsByServerIDRequest |
pb.CreateProxyConfigRequest | pb.ListProxyConfigsRequest | pb.UpdateProxyConfigRequest |
pb.DeleteProxyConfigRequest | pb.GetProxyConfigRequest | pb.SignTokenRequest |
pb.StartProxyRequest | pb.StopProxyRequest
pb.StartProxyRequest | pb.StopProxyRequest |
pb.CreateWorkerRequest | pb.RemoveWorkerRequest | pb.RunWorkerRequest | pb.StopWorkerRequest | pb.UpdateWorkerRequest | pb.GetWorkerRequest |
pb.ListWorkersRequest | pb.CreateWorkerIngressRequest | pb.GetWorkerIngressRequest |
pb.GetWorkerStatusRequest | pb.InstallWorkerdRequest
}
func GetProtoRequest[T ReqType](c *gin.Context) (r *T, err error) {

View File

@@ -26,7 +26,10 @@ type RespType interface {
pb.GetProxyStatsByClientIDResponse | pb.GetProxyStatsByServerIDResponse |
pb.CreateProxyConfigResponse | pb.ListProxyConfigsResponse | pb.UpdateProxyConfigResponse |
pb.DeleteProxyConfigResponse | pb.GetProxyConfigResponse | pb.SignTokenResponse |
pb.StartProxyResponse | pb.StopProxyResponse
pb.StartProxyResponse | pb.StopProxyResponse |
pb.CreateWorkerResponse | pb.RemoveWorkerResponse | pb.RunWorkerResponse | pb.StopWorkerResponse | pb.UpdateWorkerResponse | pb.GetWorkerResponse |
pb.ListWorkersResponse | pb.CreateWorkerIngressResponse | pb.GetWorkerIngressResponse |
pb.GetWorkerStatusResponse | pb.InstallWorkerdResponse
}
func OKResp[T RespType](c *gin.Context, origin *T) {
@@ -96,6 +99,14 @@ func getEvent(origin interface{}) (pb.Event, protoreflect.ProtoMessage, error) {
return pb.Event_EVENT_STOP_FRPS, ptr, nil
case *pb.GetProxyConfigResponse:
return pb.Event_EVENT_GET_PROXY_INFO, ptr, nil
case *pb.CreateWorkerResponse:
return pb.Event_EVENT_CREATE_WORKER, ptr, nil
case *pb.RemoveWorkerResponse:
return pb.Event_EVENT_REMOVE_WORKER, ptr, nil
case *pb.GetWorkerStatusResponse:
return pb.Event_EVENT_GET_WORKER_STATUS, ptr, nil
case *pb.InstallWorkerdResponse:
return pb.Event_EVENT_INSTALL_WORKERD, ptr, nil
default:
return 0, nil, fmt.Errorf("cannot unmarshal unknown type: %T", origin)
}

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/gin-gonic/gin"
"github.com/ilyakaznacheev/cleanenv"
@@ -52,12 +54,25 @@ type Config struct {
RPCUrl string `env:"RPC_URL" env-description:"rpc url, support ws or wss or grpc scheme, eg: ws://127.0.0.1:9000"`
APIUrl string `env:"API_URL" env-description:"api url, support http or https scheme, eg: http://127.0.0.1:9000"`
TLSInsecureSkipVerify bool `env:"TLS_INSECURE_SKIP_VERIFY" env-default:"true" env-description:"skip tls verify"`
Worker struct {
WorkerdBinaryPath string `env:"WORKERD_BINARY_PATH" env-description:"workerd binary path"`
WorkerdWorkDir string `env:"WORKERD_WORK_DIR" env-default:"/tmp/frpp/workerd" env-description:"workerd work dir"`
WorkerdDownloadURL struct {
UseProxy bool `env:"USE_PROXY" env-default:"true" env-description:"use proxy"`
LinuxArm64 string `env:"LINUX_ARM64" env-default:"https://github.com/cloudflare/workerd/releases/download/v1.20250505.0/workerd-linux-arm64.gz"`
LinuxX8664 string `env:"LINUX_X86_64" env-default:"https://github.com/cloudflare/workerd/releases/download/v1.20250505.0/workerd-linux-64.gz"`
} `env-prefix:"WORKERD_DOWNLOAD_URL_" env-description:"workerd download url"`
} `env-prefix:"WORKER_" env-description:"worker's config"`
Features struct {
EnableFunctions bool `env:"ENABLE_FUNCTIONS" env-default:"true" env-description:"enable functions"`
} `env-prefix:"FEATURES_" env-description:"features config"`
} `env-prefix:"CLIENT_"`
IsDebug bool `env:"IS_DEBUG" env-default:"false" env-description:"is debug mode"`
Logger struct {
DefaultLoggerLevel string `env:"DEFAULT_LOGGER_LEVEL" env-default:"info" env-description:"frp-panel internal default logger level"`
FRPLoggerLevel string `env:"FRP_LOGGER_LEVEL" env-default:"info" env-description:"frp logger level"`
} `env-prefix:"LOGGER_"`
HTTP_PROXY string `env:"HTTP_PROXY" env-description:"http proxy"`
}
func NewConfig() Config {
@@ -115,6 +130,21 @@ func (cfg *Config) Complete() {
if len(cfg.Client.ID) == 0 {
cfg.Client.ID = hostname
}
cwd, err := os.Getwd()
if err != nil {
fmt.Println("failed to get current working directory:", err)
os.Exit(1)
}
if len(cfg.Client.Worker.WorkerdBinaryPath) == 0 {
w, _ := utils.FindExecutableNames(func(name string) bool {
return strings.HasPrefix(name, "workerd")
}, cwd, "/")
if len(w) > 0 {
cfg.Client.Worker.WorkerdBinaryPath = w[0]
}
}
}
func (cfg Config) PrintStr() string {

View File

@@ -70,6 +70,7 @@ const (
const (
PullConfigDuration = 30 * time.Second
PushProxyInfoDuration = 30 * time.Second
PullClientWorkersDuration = 30 * time.Second
)
const (
@@ -100,6 +101,50 @@ const (
const (
UserRole_Admin = "admin"
UserRole_Normal = "normal"
CapFileName = "workerd.capnp"
WorkerInfoPath = "workers"
WorkerCodePath = "src"
DBTypeSqlite = "sqlite"
DefaultHostName = "127.0.0.1"
DefaultNodeName = "default"
DefaultExternalPath = "/"
DefaultEntry = "entry.js"
DefaultSocketTemplate = "unix-abstract:/tmp/frpp-worker-%s.sock"
DefaultCode = `export default {
async fetch(req, env) {
try {
let resp = new Response("worker: " + req.url + " is online! -- " + new Date())
return resp
} catch(e) {
return new Response(e.stack, { status: 500 })
}
}
};`
DefaultConfigTemplate = `using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "{{.WorkerId}}", worker = .v{{.WorkerId}}Worker),
],
sockets = [
(
name = "{{.WorkerId}}",
address = "{{.Socket.Address}}",
http=(),
service="{{.WorkerId}}"
),
]
);
const v{{.WorkerId}}Worker :Workerd.Worker = (
modules = [
(name = "{{.CodeEntry}}", esModule = embed "src/{{.CodeEntry}}"),
],
compatibilityDate = "2023-04-03",
);`
)
type TokenStatus string
@@ -109,3 +154,23 @@ const (
TokenStatusInactive TokenStatus = "inactive"
TokenStatusRevoked TokenStatus = "revoked"
)
const (
KeyNodeName = "node_name"
KeyNodeSecret = "node_secret"
KeyNodeProto = "node_proto"
KeyWorkerProto = "worker_proto"
)
type WorkerStatus string
const (
WorkerStatus_Unknown WorkerStatus = "unknown"
WorkerStatus_Running WorkerStatus = "running"
WorkerStatus_Inactive WorkerStatus = "inactive"
)
const (
FrpProxyAnnotationsKey_Ingress = "ingress"
FrpProxyAnnotationsKey_WorkerId = "worker_id"
)

15
go.mod
View File

@@ -7,7 +7,7 @@ toolchain go1.24.1
require (
github.com/UserExistsError/conpty v0.1.4
github.com/casbin/casbin/v2 v2.105.0
github.com/casbin/gorm-adapter/v3 v3.32.0
github.com/casbin/gorm-adapter/v3 v3.29.0
github.com/coocood/freecache v1.2.4
github.com/creack/pty v1.1.24
github.com/fatedier/frp v0.62.0
@@ -25,23 +25,26 @@ require (
github.com/jackpal/gateway v1.0.16
github.com/joho/godotenv v1.5.1
github.com/kardianos/service v1.2.2
github.com/lucasepe/codename v0.2.0
github.com/samber/lo v1.47.0
github.com/shirou/gopsutil/v4 v4.24.11
github.com/shirou/gopsutil/v4 v4.25.4
github.com/sirupsen/logrus v1.9.3
github.com/sourcegraph/conc v0.3.0
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.10.0
github.com/tidwall/pretty v1.2.1
github.com/tiendc/go-deepcopy v1.2.0
go.uber.org/fx v1.23.0
go.uber.org/multierr v1.11.0
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
google.golang.org/grpc v1.67.1
google.golang.org/protobuf v1.36.5
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
gorm.io/gorm v1.25.11
k8s.io/apimachinery v0.28.8
)
@@ -60,7 +63,7 @@ require (
github.com/coreos/go-oidc/v3 v3.14.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -121,7 +124,6 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/templexxx/cpu v0.1.1 // indirect
github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
@@ -136,7 +138,6 @@ require (
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/dig v1.18.1 // indirect
go.uber.org/mock v0.5.1 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
@@ -154,7 +155,7 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/sqlserver v1.5.3 // indirect
gorm.io/plugin/dbresolver v1.5.3 // indirect
gorm.io/plugin/dbresolver v1.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20250425231648-60ec4e7a009d // indirect
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
modernc.org/libc v1.22.5 // indirect

28
go.sum
View File

@@ -38,8 +38,8 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/casbin/casbin/v2 v2.105.0 h1:dLj5P6pLApBRat9SADGiLxLZjiDPvA1bsPkyV4PGx6I=
github.com/casbin/casbin/v2 v2.105.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
github.com/casbin/gorm-adapter/v3 v3.32.0 h1:Au+IOILBIE9clox5BJhI2nA3p9t7Ep1ePlupdGbGfus=
github.com/casbin/gorm-adapter/v3 v3.32.0/go.mod h1:Zre/H8p17mpv5U3EaWgPoxLILLdXO3gHW5aoQQpUDZI=
github.com/casbin/gorm-adapter/v3 v3.29.0 h1:MpbF5JVFYOwVGaTkvwDNUV4k+5CYwAu6v83ofZHRfvM=
github.com/casbin/gorm-adapter/v3 v3.29.0/go.mod h1:C0Ew2tNYtdvDK1f+yEiKZt8XL0fcaurQhOHgxMSBM54=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -68,8 +68,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -228,6 +228,8 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasepe/codename v0.2.0 h1:zkW9mKWSO8jjVIYFyZWE9FPvBtFVJxgMpQcMkf4Vv20=
github.com/lucasepe/codename v0.2.0/go.mod h1:RDcExRuZPWp5Uz+BosvpROFTrxpt5r1vSzBObHdBdDM=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@@ -297,8 +299,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
@@ -372,8 +374,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -475,7 +477,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
@@ -533,6 +534,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
@@ -541,10 +543,10 @@ gorm.io/driver/sqlserver v1.5.3 h1:rjupPS4PVw+rjJkfvr8jn2lJ8BMhT4UW5FwuJY0P3Z0=
gorm.io/driver/sqlserver v1.5.3/go.mod h1:B+CZ0/7oFJ6tAlefsKoyxdgDCXJKSgwS2bMOQZT0I00=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/plugin/dbresolver v1.5.3 h1:wFwINGZZmttuu9h7XpvbDHd8Lf9bb8GNzp/NpAMV2wU=
gorm.io/plugin/dbresolver v1.5.3/go.mod h1:TSrVhaUg2DZAWP3PrHlDlITEJmNOkL0tFTjvTEsQ4XE=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/plugin/dbresolver v1.5.2 h1:Iut7lW4TXNoVs++I+ra3zxjSxTRj4ocIeFEVp4lLhII=
gorm.io/plugin/dbresolver v1.5.2/go.mod h1:jPh59GOQbO7v7v28ZKZPd45tr+u3vyT+8tHdfdfOWcU=
gvisor.dev/gvisor v0.0.0-20250425231648-60ec4e7a009d h1:cCKla0V7sa6eixh74LtGQXakTu5QJEzkcX7DzNRhFOE=
gvisor.dev/gvisor v0.0.0-20250425231648-60ec4e7a009d/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -167,3 +167,111 @@ message StartProxyRequest {
message StartProxyResponse {
optional common.Status status = 1;
}
message CreateWorkerRequest {
optional string client_id = 1;
optional common.Worker worker = 2;
}
message CreateWorkerResponse {
optional common.Status status = 1;
optional string worker_id = 2;
}
message RemoveWorkerRequest {
optional string client_id = 1;
optional string worker_id = 2;
}
message RemoveWorkerResponse {
optional common.Status status = 1;
}
message UpdateWorkerRequest {
repeated string client_ids = 1;
optional common.Worker worker = 2;
}
message UpdateWorkerResponse {
optional common.Status status = 1;
}
message RunWorkerRequest {
optional string client_id = 1;
optional string worker_id = 2;
}
message RunWorkerResponse {
optional common.Status status = 1;
}
message StopWorkerRequest {
optional string client_id = 1;
optional string worker_id = 2;
}
message StopWorkerResponse {
optional common.Status status = 1;
}
message ListWorkersRequest {
optional int32 page = 1;
optional int32 page_size = 2;
optional string keyword = 3;
optional string client_id = 4;
optional string server_id = 5;
}
message ListWorkersResponse {
optional common.Status status = 1;
optional int32 total = 2;
repeated common.Worker workers = 3;
}
// 为 client 在一个 server 创建ingress
message CreateWorkerIngressRequest {
optional string client_id = 1;
optional string server_id = 2;
optional string worker_id = 3;
}
message CreateWorkerIngressResponse {
optional common.Status status = 1;
}
message GetWorkerIngressRequest {
optional string worker_id = 1;
}
message GetWorkerIngressResponse {
optional common.Status status = 1;
repeated common.ProxyConfig proxy_configs = 2;
}
message GetWorkerRequest {
optional string worker_id = 1;
}
message GetWorkerResponse {
optional common.Status status = 1;
optional common.Worker worker = 2;
repeated common.Client clients = 3; // worker 已经部署到的 client
}
message GetWorkerStatusRequest {
optional string worker_id = 1;
}
message GetWorkerStatusResponse {
optional common.Status status = 1;
map<string, string> worker_status = 2; // client_id -> status
}
message InstallWorkerdRequest {
optional string client_id = 1;
optional string download_url = 2;
}
message InstallWorkerdResponse {
optional common.Status status = 1;
}

View File

@@ -97,3 +97,25 @@ message ProxyWorkingStatus {
optional string err = 4;
optional string remote_addr = 5;
}
message Worker {
optional string worker_id = 1;
optional string name = 2; // worker's name, also use at worker routing, must be unique, default is UID
optional uint32 user_id = 3; // worker's user id
optional uint32 tenant_id = 4;
optional Socket socket = 5; // worker's socket, platfrom will obtain free port while init worker
optional string code_entry = 6; // worker's entry file, default is 'entry.js'
optional string code = 7; // worker's code
optional string config_template = 8; // worker's capnp file template
}
// one WorkerList for one workerd instance
message WorkerList {
repeated Worker workers = 1;
optional string nodename = 2; // workerd runner host name, for HA
}
message Socket {
optional string name = 1;
optional string address = 2;
}

View File

@@ -24,6 +24,10 @@ enum Event {
EVENT_STOP_STREAM_LOG = 16;
EVENT_START_PTY_CONNECT = 17;
EVENT_GET_PROXY_INFO = 18;
EVENT_CREATE_WORKER = 19;
EVENT_REMOVE_WORKER = 20;
EVENT_GET_WORKER_STATUS = 21;
EVENT_INSTALL_WORKERD = 22;
}
message ServerBase {
@@ -122,10 +126,20 @@ message PTYServerMessage {
bool done = 4;
}
message ListClientWorkersRequest {
ClientBase base = 255;
}
message ListClientWorkersResponse {
common.Status status = 1;
repeated common.Worker workers = 2;
}
service Master {
rpc ServerSend(stream ClientMessage) returns(stream ServerMessage);
rpc PullClientConfig(PullClientConfigReq) returns(PullClientConfigResp);
rpc PullServerConfig(PullServerConfigReq) returns(PullServerConfigResp);
rpc ListClientWorkers(ListClientWorkersRequest) returns(ListClientWorkersResponse);
rpc FRPCAuth(FRPAuthRequest) returns(FRPAuthResponse);
rpc PushProxyInfo(PushProxyInfoReq) returns(PushProxyInfoResp);
rpc PushClientStreamLog(stream PushClientStreamLogReq) returns(PushStreamLogResp);

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"time"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/utils"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/samber/lo"
@@ -12,6 +13,7 @@ import (
type Client struct {
*ClientEntity
Workers []*Worker `json:"workers,omitempty" gorm:"many2many:worker_clients;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
}
type ClientEntity struct {
@@ -75,3 +77,22 @@ func (c *ClientEntity) MarshalJSONConfig() ([]byte, error) {
}
return json.Marshal(cliCfg)
}
func (c *ClientEntity) ToPB() *pb.Client {
resp := &pb.Client{
Id: &c.ClientID,
Secret: &c.ConnectSecret,
Config: lo.ToPtr(string(c.ConfigContent)),
Comment: &c.Comment,
ServerId: &c.ServerID,
Stopped: &c.Stopped,
OriginClientId: &c.OriginClientID,
FrpsUrl: &c.FrpsUrl,
Ephemeral: &c.Ephemeral,
}
if c.LastSeenAt != nil {
resp.LastSeenAt = lo.ToPtr(c.LastSeenAt.UnixMilli())
}
return resp
}

View File

@@ -35,6 +35,9 @@ func (dbm *dbManagerImpl) Init() {
if err := db.AutoMigrate(&HistoryProxyStats{}); err != nil {
logger.Logger(context.Background()).WithError(err).Fatalf("cannot init db table [%s]", (&HistoryProxyStats{}).TableName())
}
if err := db.AutoMigrate(&Worker{}); err != nil {
logger.Logger(context.Background()).WithError(err).Fatalf("cannot init db table [%s]", (&Worker{}).TableName())
}
if err := db.AutoMigrate(&ProxyConfig{}); err != nil {
logger.Logger(context.Background()).WithError(err).Fatalf("cannot init db table [%s]", (&ProxyConfig{}).TableName())
}

View File

@@ -3,13 +3,19 @@ package models
import (
"fmt"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/samber/lo"
"gorm.io/gorm"
)
type ProxyConfig struct {
*gorm.Model
*ProxyConfigEntity
WorkerID string `gorm:"type:varchar(255);index"` // 引用的worker
Worker Worker
}
type ProxyConfigEntity struct {
@@ -53,3 +59,36 @@ func (p *ProxyConfigEntity) GetTypedProxyConfig() (v1.TypedProxyConfig, error) {
err := cfg.UnmarshalJSON(p.Content)
return cfg, err
}
func (p *ProxyConfig) GetTypedProxyConfig() (v1.TypedProxyConfig, error) {
return p.ProxyConfigEntity.GetTypedProxyConfig()
}
func (p *ProxyConfig) FillClientConfig(cli *ClientEntity) error {
return p.ProxyConfigEntity.FillClientConfig(cli)
}
func (p *ProxyConfig) FillTypedProxyConfig(cfg v1.TypedProxyConfig) error {
annotations := cfg.GetBaseConfig().Annotations
if len(annotations) > 0 {
if annotations[defs.FrpProxyAnnotationsKey_Ingress] != "" && len(annotations[defs.FrpProxyAnnotationsKey_WorkerId]) > 0 {
workerId := annotations[defs.FrpProxyAnnotationsKey_WorkerId]
p.WorkerID = workerId
}
}
return p.ProxyConfigEntity.FillTypedProxyConfig(cfg)
}
func (p *ProxyConfig) ToPB() *pb.ProxyConfig {
return &pb.ProxyConfig{
Id: lo.ToPtr(uint32(p.ID)),
Name: lo.ToPtr(p.Name),
Type: lo.ToPtr(p.Type),
Config: lo.ToPtr(string(p.Content)),
Stopped: lo.ToPtr(p.Stopped),
ServerId: lo.ToPtr(p.ServerID),
ClientId: lo.ToPtr(p.ClientID),
OriginClientId: lo.ToPtr(p.OriginClientID),
}
}

86
models/worker.go Normal file
View File

@@ -0,0 +1,86 @@
package models
import (
"time"
"github.com/VaalaCat/frp-panel/pb"
"github.com/samber/lo"
"gorm.io/gorm"
)
type WorkerModel struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Worker struct {
*WorkerModel
*WorkerEntity
Clients []Client `gorm:"many2many:worker_clients;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
}
type WorkerEntity struct {
ID string `gorm:"type:varchar(255);uniqueIndex;not null;primaryKey"`
Name string `gorm:"type:varchar(255);index"`
UserId uint32 `gorm:"index"`
TenantId uint32 `gorm:"index"`
Socket JSON[*pb.Socket]
CodeEntry string
Code string
ConfigTemplate string
}
func (w *Worker) TableName() string {
return "workers"
}
func (w *WorkerEntity) FromPB(worker *pb.Worker) *WorkerEntity {
w.ID = worker.GetWorkerId()
w.Name = worker.GetName()
w.UserId = uint32(worker.GetUserId())
w.TenantId = uint32(worker.GetTenantId())
w.Socket = JSON[*pb.Socket]{Data: worker.GetSocket()}
w.CodeEntry = worker.GetCodeEntry()
w.Code = worker.GetCode()
w.ConfigTemplate = worker.GetConfigTemplate()
return w
}
func (w *WorkerEntity) ToPB() *pb.Worker {
return &pb.Worker{
WorkerId: lo.ToPtr(w.ID),
Name: lo.ToPtr(w.Name),
UserId: lo.ToPtr(uint32(w.UserId)),
TenantId: lo.ToPtr(uint32(w.TenantId)),
Socket: w.Socket.Data,
CodeEntry: lo.ToPtr(w.CodeEntry),
Code: lo.ToPtr(w.Code),
ConfigTemplate: lo.ToPtr(w.ConfigTemplate),
}
}
func (w *Worker) FromPB(worker *pb.Worker) *Worker {
if w.WorkerEntity == nil {
w.WorkerEntity = &WorkerEntity{}
}
if w.WorkerModel == nil {
w.WorkerModel = &WorkerModel{}
}
w.WorkerEntity = w.WorkerEntity.FromPB(worker)
return w
}
func (w *Worker) ToPB() *pb.Worker {
if w.WorkerEntity == nil {
w.WorkerEntity = &WorkerEntity{}
}
if w.WorkerModel == nil {
w.WorkerModel = &WorkerModel{}
}
ret := w.WorkerEntity.ToPB()
return ret
}

File diff suppressed because it is too large Load Diff

View File

@@ -871,6 +871,211 @@ func (x *ProxyWorkingStatus) GetRemoteAddr() string {
return ""
}
type Worker struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkerId *string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3,oneof" json:"worker_id,omitempty"`
Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` // worker's name, also use at worker routing, must be unique, default is UID
UserId *uint32 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3,oneof" json:"user_id,omitempty"` // worker's user id
TenantId *uint32 `protobuf:"varint,4,opt,name=tenant_id,json=tenantId,proto3,oneof" json:"tenant_id,omitempty"`
Socket *Socket `protobuf:"bytes,5,opt,name=socket,proto3,oneof" json:"socket,omitempty"` // worker's socket, platfrom will obtain free port while init worker
CodeEntry *string `protobuf:"bytes,6,opt,name=code_entry,json=codeEntry,proto3,oneof" json:"code_entry,omitempty"` // worker's entry file, default is 'entry.js'
Code *string `protobuf:"bytes,7,opt,name=code,proto3,oneof" json:"code,omitempty"` // worker's code
ConfigTemplate *string `protobuf:"bytes,8,opt,name=config_template,json=configTemplate,proto3,oneof" json:"config_template,omitempty"` // worker's capnp file template
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Worker) Reset() {
*x = Worker{}
mi := &file_common_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Worker) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Worker) ProtoMessage() {}
func (x *Worker) ProtoReflect() protoreflect.Message {
mi := &file_common_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Worker.ProtoReflect.Descriptor instead.
func (*Worker) Descriptor() ([]byte, []int) {
return file_common_proto_rawDescGZIP(), []int{9}
}
func (x *Worker) GetWorkerId() string {
if x != nil && x.WorkerId != nil {
return *x.WorkerId
}
return ""
}
func (x *Worker) GetName() string {
if x != nil && x.Name != nil {
return *x.Name
}
return ""
}
func (x *Worker) GetUserId() uint32 {
if x != nil && x.UserId != nil {
return *x.UserId
}
return 0
}
func (x *Worker) GetTenantId() uint32 {
if x != nil && x.TenantId != nil {
return *x.TenantId
}
return 0
}
func (x *Worker) GetSocket() *Socket {
if x != nil {
return x.Socket
}
return nil
}
func (x *Worker) GetCodeEntry() string {
if x != nil && x.CodeEntry != nil {
return *x.CodeEntry
}
return ""
}
func (x *Worker) GetCode() string {
if x != nil && x.Code != nil {
return *x.Code
}
return ""
}
func (x *Worker) GetConfigTemplate() string {
if x != nil && x.ConfigTemplate != nil {
return *x.ConfigTemplate
}
return ""
}
// one WorkerList for one workerd instance
type WorkerList struct {
state protoimpl.MessageState `protogen:"open.v1"`
Workers []*Worker `protobuf:"bytes,1,rep,name=workers,proto3" json:"workers,omitempty"`
Nodename *string `protobuf:"bytes,2,opt,name=nodename,proto3,oneof" json:"nodename,omitempty"` // workerd runner host name, for HA
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WorkerList) Reset() {
*x = WorkerList{}
mi := &file_common_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WorkerList) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WorkerList) ProtoMessage() {}
func (x *WorkerList) ProtoReflect() protoreflect.Message {
mi := &file_common_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WorkerList.ProtoReflect.Descriptor instead.
func (*WorkerList) Descriptor() ([]byte, []int) {
return file_common_proto_rawDescGZIP(), []int{10}
}
func (x *WorkerList) GetWorkers() []*Worker {
if x != nil {
return x.Workers
}
return nil
}
func (x *WorkerList) GetNodename() string {
if x != nil && x.Nodename != nil {
return *x.Nodename
}
return ""
}
type Socket struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"`
Address *string `protobuf:"bytes,2,opt,name=address,proto3,oneof" json:"address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Socket) Reset() {
*x = Socket{}
mi := &file_common_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Socket) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Socket) ProtoMessage() {}
func (x *Socket) ProtoReflect() protoreflect.Message {
mi := &file_common_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Socket.ProtoReflect.Descriptor instead.
func (*Socket) Descriptor() ([]byte, []int) {
return file_common_proto_rawDescGZIP(), []int{11}
}
func (x *Socket) GetName() string {
if x != nil && x.Name != nil {
return *x.Name
}
return ""
}
func (x *Socket) GetAddress() string {
if x != nil && x.Address != nil {
return *x.Address
}
return ""
}
var File_common_proto protoreflect.FileDescriptor
const file_common_proto_rawDesc = "" +
@@ -999,7 +1204,39 @@ const file_common_proto_rawDesc = "" +
"\x05_typeB\t\n" +
"\a_statusB\x06\n" +
"\x04_errB\x0e\n" +
"\f_remote_addr*\xbc\x01\n" +
"\f_remote_addr\"\x83\x03\n" +
"\x06Worker\x12 \n" +
"\tworker_id\x18\x01 \x01(\tH\x00R\bworkerId\x88\x01\x01\x12\x17\n" +
"\x04name\x18\x02 \x01(\tH\x01R\x04name\x88\x01\x01\x12\x1c\n" +
"\auser_id\x18\x03 \x01(\rH\x02R\x06userId\x88\x01\x01\x12 \n" +
"\ttenant_id\x18\x04 \x01(\rH\x03R\btenantId\x88\x01\x01\x12+\n" +
"\x06socket\x18\x05 \x01(\v2\x0e.common.SocketH\x04R\x06socket\x88\x01\x01\x12\"\n" +
"\n" +
"code_entry\x18\x06 \x01(\tH\x05R\tcodeEntry\x88\x01\x01\x12\x17\n" +
"\x04code\x18\a \x01(\tH\x06R\x04code\x88\x01\x01\x12,\n" +
"\x0fconfig_template\x18\b \x01(\tH\aR\x0econfigTemplate\x88\x01\x01B\f\n" +
"\n" +
"_worker_idB\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_user_idB\f\n" +
"\n" +
"_tenant_idB\t\n" +
"\a_socketB\r\n" +
"\v_code_entryB\a\n" +
"\x05_codeB\x12\n" +
"\x10_config_template\"d\n" +
"\n" +
"WorkerList\x12(\n" +
"\aworkers\x18\x01 \x03(\v2\x0e.common.WorkerR\aworkers\x12\x1f\n" +
"\bnodename\x18\x02 \x01(\tH\x00R\bnodename\x88\x01\x01B\v\n" +
"\t_nodename\"U\n" +
"\x06Socket\x12\x17\n" +
"\x04name\x18\x01 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x02 \x01(\tH\x01R\aaddress\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_address*\xbc\x01\n" +
"\bRespCode\x12\x19\n" +
"\x15RESP_CODE_UNSPECIFIED\x10\x00\x12\x15\n" +
"\x11RESP_CODE_SUCCESS\x10\x01\x12\x17\n" +
@@ -1027,7 +1264,7 @@ func file_common_proto_rawDescGZIP() []byte {
}
var file_common_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_common_proto_goTypes = []any{
(RespCode)(0), // 0: common.RespCode
(ClientType)(0), // 1: common.ClientType
@@ -1040,15 +1277,20 @@ var file_common_proto_goTypes = []any{
(*ProxyInfo)(nil), // 8: common.ProxyInfo
(*ProxyConfig)(nil), // 9: common.ProxyConfig
(*ProxyWorkingStatus)(nil), // 10: common.ProxyWorkingStatus
(*Worker)(nil), // 11: common.Worker
(*WorkerList)(nil), // 12: common.WorkerList
(*Socket)(nil), // 13: common.Socket
}
var file_common_proto_depIdxs = []int32{
0, // 0: common.Status.code:type_name -> common.RespCode
2, // 1: common.CommonResponse.status:type_name -> common.Status
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
13, // 2: common.Worker.socket:type_name -> common.Socket
11, // 3: common.WorkerList.workers:type_name -> common.Worker
4, // [4:4] is the sub-list for method output_type
4, // [4:4] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_common_proto_init() }
@@ -1064,13 +1306,16 @@ func file_common_proto_init() {
file_common_proto_msgTypes[6].OneofWrappers = []any{}
file_common_proto_msgTypes[7].OneofWrappers = []any{}
file_common_proto_msgTypes[8].OneofWrappers = []any{}
file_common_proto_msgTypes[9].OneofWrappers = []any{}
file_common_proto_msgTypes[10].OneofWrappers = []any{}
file_common_proto_msgTypes[11].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)),
NumEnums: 2,
NumMessages: 9,
NumMessages: 12,
NumExtensions: 0,
NumServices: 0,
},

View File

@@ -43,6 +43,10 @@ const (
Event_EVENT_STOP_STREAM_LOG Event = 16
Event_EVENT_START_PTY_CONNECT Event = 17
Event_EVENT_GET_PROXY_INFO Event = 18
Event_EVENT_CREATE_WORKER Event = 19
Event_EVENT_REMOVE_WORKER Event = 20
Event_EVENT_GET_WORKER_STATUS Event = 21
Event_EVENT_INSTALL_WORKERD Event = 22
)
// Enum value maps for Event.
@@ -67,6 +71,10 @@ var (
16: "EVENT_STOP_STREAM_LOG",
17: "EVENT_START_PTY_CONNECT",
18: "EVENT_GET_PROXY_INFO",
19: "EVENT_CREATE_WORKER",
20: "EVENT_REMOVE_WORKER",
21: "EVENT_GET_WORKER_STATUS",
22: "EVENT_INSTALL_WORKERD",
}
Event_value = map[string]int32{
"EVENT_UNSPECIFIED": 0,
@@ -88,6 +96,10 @@ var (
"EVENT_STOP_STREAM_LOG": 16,
"EVENT_START_PTY_CONNECT": 17,
"EVENT_GET_PROXY_INFO": 18,
"EVENT_CREATE_WORKER": 19,
"EVENT_REMOVE_WORKER": 20,
"EVENT_GET_WORKER_STATUS": 21,
"EVENT_INSTALL_WORKERD": 22,
}
)
@@ -1096,6 +1108,102 @@ func (x *PTYServerMessage) GetDone() bool {
return false
}
type ListClientWorkersRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Base *ClientBase `protobuf:"bytes,255,opt,name=base,proto3" json:"base,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListClientWorkersRequest) Reset() {
*x = ListClientWorkersRequest{}
mi := &file_rpc_master_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListClientWorkersRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListClientWorkersRequest) ProtoMessage() {}
func (x *ListClientWorkersRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_master_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListClientWorkersRequest.ProtoReflect.Descriptor instead.
func (*ListClientWorkersRequest) Descriptor() ([]byte, []int) {
return file_rpc_master_proto_rawDescGZIP(), []int{17}
}
func (x *ListClientWorkersRequest) GetBase() *ClientBase {
if x != nil {
return x.Base
}
return nil
}
type ListClientWorkersResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status *Status `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
Workers []*Worker `protobuf:"bytes,2,rep,name=workers,proto3" json:"workers,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListClientWorkersResponse) Reset() {
*x = ListClientWorkersResponse{}
mi := &file_rpc_master_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListClientWorkersResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListClientWorkersResponse) ProtoMessage() {}
func (x *ListClientWorkersResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_master_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListClientWorkersResponse.ProtoReflect.Descriptor instead.
func (*ListClientWorkersResponse) Descriptor() ([]byte, []int) {
return file_rpc_master_proto_rawDescGZIP(), []int{18}
}
func (x *ListClientWorkersResponse) GetStatus() *Status {
if x != nil {
return x.Status
}
return nil
}
func (x *ListClientWorkersResponse) GetWorkers() []*Worker {
if x != nil {
return x.Workers
}
return nil
}
var File_rpc_master_proto protoreflect.FileDescriptor
const file_rpc_master_proto_rawDesc = "" +
@@ -1172,7 +1280,12 @@ const file_rpc_master_proto_rawDesc = "" +
"\x04done\x18\x04 \x01(\bR\x04doneB\a\n" +
"\x05_dataB\t\n" +
"\a_heightB\b\n" +
"\x06_width*\xb5\x03\n" +
"\x06_width\"C\n" +
"\x18ListClientWorkersRequest\x12'\n" +
"\x04base\x18\xff\x01 \x01(\v2\x12.master.ClientBaseR\x04base\"m\n" +
"\x19ListClientWorkersResponse\x12&\n" +
"\x06status\x18\x01 \x01(\v2\x0e.common.StatusR\x06status\x12(\n" +
"\aworkers\x18\x02 \x03(\v2\x0e.common.WorkerR\aworkers*\x9f\x04\n" +
"\x05Event\x12\x15\n" +
"\x11EVENT_UNSPECIFIED\x10\x00\x12\x19\n" +
"\x15EVENT_REGISTER_CLIENT\x10\x01\x12\x19\n" +
@@ -1196,12 +1309,17 @@ const file_rpc_master_proto_rawDesc = "" +
"\x16EVENT_START_STREAM_LOG\x10\x0f\x12\x19\n" +
"\x15EVENT_STOP_STREAM_LOG\x10\x10\x12\x1b\n" +
"\x17EVENT_START_PTY_CONNECT\x10\x11\x12\x18\n" +
"\x14EVENT_GET_PROXY_INFO\x10\x122\xd7\x04\n" +
"\x14EVENT_GET_PROXY_INFO\x10\x12\x12\x17\n" +
"\x13EVENT_CREATE_WORKER\x10\x13\x12\x17\n" +
"\x13EVENT_REMOVE_WORKER\x10\x14\x12\x1b\n" +
"\x17EVENT_GET_WORKER_STATUS\x10\x15\x12\x19\n" +
"\x15EVENT_INSTALL_WORKERD\x10\x162\xb1\x05\n" +
"\x06Master\x12>\n" +
"\n" +
"ServerSend\x12\x15.master.ClientMessage\x1a\x15.master.ServerMessage(\x010\x01\x12M\n" +
"\x10PullClientConfig\x12\x1b.master.PullClientConfigReq\x1a\x1c.master.PullClientConfigResp\x12M\n" +
"\x10PullServerConfig\x12\x1b.master.PullServerConfigReq\x1a\x1c.master.PullServerConfigResp\x12;\n" +
"\x10PullServerConfig\x12\x1b.master.PullServerConfigReq\x1a\x1c.master.PullServerConfigResp\x12X\n" +
"\x11ListClientWorkers\x12 .master.ListClientWorkersRequest\x1a!.master.ListClientWorkersResponse\x12;\n" +
"\bFRPCAuth\x12\x16.master.FRPAuthRequest\x1a\x17.master.FRPAuthResponse\x12D\n" +
"\rPushProxyInfo\x12\x18.master.PushProxyInfoReq\x1a\x19.master.PushProxyInfoResp\x12R\n" +
"\x13PushClientStreamLog\x12\x1e.master.PushClientStreamLogReq\x1a\x19.master.PushStreamLogResp(\x01\x12R\n" +
@@ -1222,7 +1340,7 @@ func file_rpc_master_proto_rawDescGZIP() []byte {
}
var file_rpc_master_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_rpc_master_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_rpc_master_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
var file_rpc_master_proto_goTypes = []any{
(Event)(0), // 0: master.Event
(*ServerBase)(nil), // 1: master.ServerBase
@@ -1242,51 +1360,59 @@ var file_rpc_master_proto_goTypes = []any{
(*PushStreamLogResp)(nil), // 15: master.PushStreamLogResp
(*PTYClientMessage)(nil), // 16: master.PTYClientMessage
(*PTYServerMessage)(nil), // 17: master.PTYServerMessage
(*Status)(nil), // 18: common.Status
(*Client)(nil), // 19: common.Client
(*Server)(nil), // 20: common.Server
(*ProxyInfo)(nil), // 21: common.ProxyInfo
(*ListClientWorkersRequest)(nil), // 18: master.ListClientWorkersRequest
(*ListClientWorkersResponse)(nil), // 19: master.ListClientWorkersResponse
(*Status)(nil), // 20: common.Status
(*Client)(nil), // 21: common.Client
(*Server)(nil), // 22: common.Server
(*ProxyInfo)(nil), // 23: common.ProxyInfo
(*Worker)(nil), // 24: common.Worker
}
var file_rpc_master_proto_depIdxs = []int32{
0, // 0: master.ServerMessage.event:type_name -> master.Event
0, // 1: master.ClientMessage.event:type_name -> master.Event
2, // 2: master.PullClientConfigReq.base:type_name -> master.ClientBase
18, // 3: master.PullClientConfigResp.status:type_name -> common.Status
19, // 4: master.PullClientConfigResp.client:type_name -> common.Client
20, // 3: master.PullClientConfigResp.status:type_name -> common.Status
21, // 4: master.PullClientConfigResp.client:type_name -> common.Client
1, // 5: master.PullServerConfigReq.base:type_name -> master.ServerBase
18, // 6: master.PullServerConfigResp.status:type_name -> common.Status
20, // 7: master.PullServerConfigResp.server:type_name -> common.Server
20, // 6: master.PullServerConfigResp.status:type_name -> common.Status
22, // 7: master.PullServerConfigResp.server:type_name -> common.Server
1, // 8: master.FRPAuthRequest.base:type_name -> master.ServerBase
18, // 9: master.FRPAuthResponse.status:type_name -> common.Status
20, // 9: master.FRPAuthResponse.status:type_name -> common.Status
1, // 10: master.PushProxyInfoReq.base:type_name -> master.ServerBase
21, // 11: master.PushProxyInfoReq.proxy_infos:type_name -> common.ProxyInfo
18, // 12: master.PushProxyInfoResp.status:type_name -> common.Status
23, // 11: master.PushProxyInfoReq.proxy_infos:type_name -> common.ProxyInfo
20, // 12: master.PushProxyInfoResp.status:type_name -> common.Status
1, // 13: master.PushServerStreamLogReq.base:type_name -> master.ServerBase
2, // 14: master.PushClientStreamLogReq.base:type_name -> master.ClientBase
18, // 15: master.PushStreamLogResp.status:type_name -> common.Status
20, // 15: master.PushStreamLogResp.status:type_name -> common.Status
1, // 16: master.PTYClientMessage.server_base:type_name -> master.ServerBase
2, // 17: master.PTYClientMessage.client_base:type_name -> master.ClientBase
4, // 18: master.Master.ServerSend:input_type -> master.ClientMessage
5, // 19: master.Master.PullClientConfig:input_type -> master.PullClientConfigReq
7, // 20: master.Master.PullServerConfig:input_type -> master.PullServerConfigReq
9, // 21: master.Master.FRPCAuth:input_type -> master.FRPAuthRequest
11, // 22: master.Master.PushProxyInfo:input_type -> master.PushProxyInfoReq
14, // 23: master.Master.PushClientStreamLog:input_type -> master.PushClientStreamLogReq
13, // 24: master.Master.PushServerStreamLog:input_type -> master.PushServerStreamLogReq
16, // 25: master.Master.PTYConnect:input_type -> master.PTYClientMessage
3, // 26: master.Master.ServerSend:output_type -> master.ServerMessage
6, // 27: master.Master.PullClientConfig:output_type -> master.PullClientConfigResp
8, // 28: master.Master.PullServerConfig:output_type -> master.PullServerConfigResp
10, // 29: master.Master.FRPCAuth:output_type -> master.FRPAuthResponse
12, // 30: master.Master.PushProxyInfo:output_type -> master.PushProxyInfoResp
15, // 31: master.Master.PushClientStreamLog:output_type -> master.PushStreamLogResp
15, // 32: master.Master.PushServerStreamLog:output_type -> master.PushStreamLogResp
17, // 33: master.Master.PTYConnect:output_type -> master.PTYServerMessage
26, // [26:34] is the sub-list for method output_type
18, // [18:26] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
2, // 18: master.ListClientWorkersRequest.base:type_name -> master.ClientBase
20, // 19: master.ListClientWorkersResponse.status:type_name -> common.Status
24, // 20: master.ListClientWorkersResponse.workers:type_name -> common.Worker
4, // 21: master.Master.ServerSend:input_type -> master.ClientMessage
5, // 22: master.Master.PullClientConfig:input_type -> master.PullClientConfigReq
7, // 23: master.Master.PullServerConfig:input_type -> master.PullServerConfigReq
18, // 24: master.Master.ListClientWorkers:input_type -> master.ListClientWorkersRequest
9, // 25: master.Master.FRPCAuth:input_type -> master.FRPAuthRequest
11, // 26: master.Master.PushProxyInfo:input_type -> master.PushProxyInfoReq
14, // 27: master.Master.PushClientStreamLog:input_type -> master.PushClientStreamLogReq
13, // 28: master.Master.PushServerStreamLog:input_type -> master.PushServerStreamLogReq
16, // 29: master.Master.PTYConnect:input_type -> master.PTYClientMessage
3, // 30: master.Master.ServerSend:output_type -> master.ServerMessage
6, // 31: master.Master.PullClientConfig:output_type -> master.PullClientConfigResp
8, // 32: master.Master.PullServerConfig:output_type -> master.PullServerConfigResp
19, // 33: master.Master.ListClientWorkers:output_type -> master.ListClientWorkersResponse
10, // 34: master.Master.FRPCAuth:output_type -> master.FRPAuthResponse
12, // 35: master.Master.PushProxyInfo:output_type -> master.PushProxyInfoResp
15, // 36: master.Master.PushClientStreamLog:output_type -> master.PushStreamLogResp
15, // 37: master.Master.PushServerStreamLog:output_type -> master.PushStreamLogResp
17, // 38: master.Master.PTYConnect:output_type -> master.PTYServerMessage
30, // [30:39] is the sub-list for method output_type
21, // [21:30] is the sub-list for method input_type
21, // [21:21] is the sub-list for extension type_name
21, // [21:21] is the sub-list for extension extendee
0, // [0:21] is the sub-list for field type_name
}
func init() { file_rpc_master_proto_init() }
@@ -1306,7 +1432,7 @@ func file_rpc_master_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_master_proto_rawDesc), len(file_rpc_master_proto_rawDesc)),
NumEnums: 1,
NumMessages: 17,
NumMessages: 19,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -22,6 +22,7 @@ const (
Master_ServerSend_FullMethodName = "/master.Master/ServerSend"
Master_PullClientConfig_FullMethodName = "/master.Master/PullClientConfig"
Master_PullServerConfig_FullMethodName = "/master.Master/PullServerConfig"
Master_ListClientWorkers_FullMethodName = "/master.Master/ListClientWorkers"
Master_FRPCAuth_FullMethodName = "/master.Master/FRPCAuth"
Master_PushProxyInfo_FullMethodName = "/master.Master/PushProxyInfo"
Master_PushClientStreamLog_FullMethodName = "/master.Master/PushClientStreamLog"
@@ -36,6 +37,7 @@ type MasterClient interface {
ServerSend(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ClientMessage, ServerMessage], error)
PullClientConfig(ctx context.Context, in *PullClientConfigReq, opts ...grpc.CallOption) (*PullClientConfigResp, error)
PullServerConfig(ctx context.Context, in *PullServerConfigReq, opts ...grpc.CallOption) (*PullServerConfigResp, error)
ListClientWorkers(ctx context.Context, in *ListClientWorkersRequest, opts ...grpc.CallOption) (*ListClientWorkersResponse, error)
FRPCAuth(ctx context.Context, in *FRPAuthRequest, opts ...grpc.CallOption) (*FRPAuthResponse, error)
PushProxyInfo(ctx context.Context, in *PushProxyInfoReq, opts ...grpc.CallOption) (*PushProxyInfoResp, error)
PushClientStreamLog(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushClientStreamLogReq, PushStreamLogResp], error)
@@ -84,6 +86,16 @@ func (c *masterClient) PullServerConfig(ctx context.Context, in *PullServerConfi
return out, nil
}
func (c *masterClient) ListClientWorkers(ctx context.Context, in *ListClientWorkersRequest, opts ...grpc.CallOption) (*ListClientWorkersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListClientWorkersResponse)
err := c.cc.Invoke(ctx, Master_ListClientWorkers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *masterClient) FRPCAuth(ctx context.Context, in *FRPAuthRequest, opts ...grpc.CallOption) (*FRPAuthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FRPAuthResponse)
@@ -150,6 +162,7 @@ type MasterServer interface {
ServerSend(grpc.BidiStreamingServer[ClientMessage, ServerMessage]) error
PullClientConfig(context.Context, *PullClientConfigReq) (*PullClientConfigResp, error)
PullServerConfig(context.Context, *PullServerConfigReq) (*PullServerConfigResp, error)
ListClientWorkers(context.Context, *ListClientWorkersRequest) (*ListClientWorkersResponse, error)
FRPCAuth(context.Context, *FRPAuthRequest) (*FRPAuthResponse, error)
PushProxyInfo(context.Context, *PushProxyInfoReq) (*PushProxyInfoResp, error)
PushClientStreamLog(grpc.ClientStreamingServer[PushClientStreamLogReq, PushStreamLogResp]) error
@@ -174,6 +187,9 @@ func (UnimplementedMasterServer) PullClientConfig(context.Context, *PullClientCo
func (UnimplementedMasterServer) PullServerConfig(context.Context, *PullServerConfigReq) (*PullServerConfigResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method PullServerConfig not implemented")
}
func (UnimplementedMasterServer) ListClientWorkers(context.Context, *ListClientWorkersRequest) (*ListClientWorkersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListClientWorkers not implemented")
}
func (UnimplementedMasterServer) FRPCAuth(context.Context, *FRPAuthRequest) (*FRPAuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FRPCAuth not implemented")
}
@@ -253,6 +269,24 @@ func _Master_PullServerConfig_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _Master_ListClientWorkers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListClientWorkersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MasterServer).ListClientWorkers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Master_ListClientWorkers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MasterServer).ListClientWorkers(ctx, req.(*ListClientWorkersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Master_FRPCAuth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FRPAuthRequest)
if err := dec(in); err != nil {
@@ -325,6 +359,10 @@ var Master_ServiceDesc = grpc.ServiceDesc{
MethodName: "PullServerConfig",
Handler: _Master_PullServerConfig_Handler,
},
{
MethodName: "ListClientWorkers",
Handler: _Master_ListClientWorkers_Handler,
},
{
MethodName: "FRPCAuth",
Handler: _Master_FRPCAuth_Handler,

View File

@@ -26,6 +26,28 @@ type application struct {
currentRole string
permManager PermissionManager
enforcer *casbin.Enforcer
workerExecManager WorkerExecManager
workersManager WorkersManager
}
// GetWorkersManager implements Application.
func (a *application) GetWorkersManager() WorkersManager {
return a.workersManager
}
// SetWorkersManager implements Application.
func (a *application) SetWorkersManager(w WorkersManager) {
a.workersManager = w
}
// GetWorkerExecManager implements Application.
func (a *application) GetWorkerExecManager() WorkerExecManager {
return a.workerExecManager
}
// SetWorkerExecManager implements Application.
func (a *application) SetWorkerExecManager(w WorkerExecManager) {
a.workerExecManager = w
}
// GetEnforcer implements Application.

View File

@@ -43,6 +43,10 @@ type Application interface {
SetEnforcer(*casbin.Enforcer)
GetPermManager() PermissionManager
SetPermManager(PermissionManager)
GetWorkerExecManager() WorkerExecManager
SetWorkerExecManager(WorkerExecManager)
GetWorkersManager() WorkersManager
SetWorkersManager(WorkersManager)
}
type Context struct {

View File

@@ -61,7 +61,7 @@ func WrapperServerMsg[T common.ReqType, U common.RespType](appInstance Applicati
cliMsg, err := common.ProtoResp(resp)
if err != nil {
logger.Logger(context.Background()).WithError(err).Errorf("cannot marshal")
logger.Logger(context.Background()).WithError(err).Errorf("cannot marshal, may need to add this type to [getEvent] function")
return &pb.ClientMessage{
Event: pb.Event_EVENT_ERROR,
Data: []byte(err.Error()),

View File

@@ -171,3 +171,30 @@ type PermissionManager interface {
RevokeGroupPermission(groupID string, objType defs.RBACObj, objID string, action defs.RBACAction, tenantID int) (bool, error)
RevokeUserPermission(userID int, objType defs.RBACObj, objID string, action defs.RBACAction, tenantID int) (bool, error)
}
// services/workerd/exec_manager.go
type WorkerExecManager interface {
RunCmd(workerId string, cwd string, argv []string)
ExitCmd(workerId string)
ExitAllCmd()
UpdateBinaryPath(path string)
}
// services/workerd/workerd.go
type WorkerController interface {
RunWorker(c *Context)
StopWorker(c *Context)
// GetWorkerStatus(c *Context) defs.WorkerStatus
GarbageCollect()
Init(c *Context) error
}
// services/workerd/workers_manager.go
type WorkersManager interface {
GetWorker(ctx *Context, id string) (WorkerController, bool)
RunWorker(ctx *Context, id string, worker WorkerController) error
StopWorker(ctx *Context, id string) error
GetWorkerStatus(ctx *Context, id string) (defs.WorkerStatus, error)
// install workerd bin to workerd bin path, if not specified, use default path /usr/local/bin/workerd
InstallWorkerd(ctx *Context, url string, path string) (string, error)
}

View File

@@ -29,7 +29,7 @@ func (q *queryImpl) ValidateClientSecret(clientID, clientSecret string) (*models
return c.ClientEntity, nil
}
func (q *queryImpl) AdminGetClientByClientID(clientID string) (*models.ClientEntity, error) {
func (q *queryImpl) AdminGetClientByClientID(clientID string) (*models.Client, error) {
if clientID == "" {
return nil, fmt.Errorf("invalid client id")
}
@@ -43,10 +43,10 @@ func (q *queryImpl) AdminGetClientByClientID(clientID string) (*models.ClientEnt
if err != nil {
return nil, err
}
return c.ClientEntity, nil
return c, nil
}
func (q *queryImpl) GetClientByClientID(userInfo models.UserInfo, clientID string) (*models.ClientEntity, error) {
func (q *queryImpl) GetClientByClientID(userInfo models.UserInfo, clientID string) (*models.Client, error) {
if clientID == "" {
return nil, fmt.Errorf("invalid client id")
}
@@ -62,7 +62,22 @@ func (q *queryImpl) GetClientByClientID(userInfo models.UserInfo, clientID strin
if err != nil {
return nil, err
}
return c.ClientEntity, nil
return c, nil
}
func (q *queryImpl) GetClientsByClientIDs(userInfo models.UserInfo, clientIDs []string) ([]*models.Client, error) {
if len(clientIDs) == 0 {
return nil, fmt.Errorf("invalid client ids")
}
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
cs := []*models.Client{}
err := db.Where("client_id IN ?", clientIDs).Find(&cs).Error
if err != nil {
return nil, err
}
return cs, nil
}
func (q *queryImpl) GetClientByFilter(userInfo models.UserInfo, client *models.ClientEntity, shadow *bool) (*models.ClientEntity, error) {

View File

@@ -548,3 +548,21 @@ func (q *queryImpl) CountProxyConfigsWithFiltersAndKeyword(userInfo models.UserI
}
return count, nil
}
func (q *queryImpl) GetProxyConfigsByWorkerId(userInfo models.UserInfo, workerID string) ([]*models.ProxyConfig, error) {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
items := []*models.ProxyConfig{}
err := db.
Where(&models.ProxyConfig{ProxyConfigEntity: &models.ProxyConfigEntity{
UserID: userInfo.GetUserID(),
TenantID: userInfo.GetTenantID(),
},
WorkerID: workerID,
}).
Find(&items).Error
if err != nil {
return nil, err
}
return items, nil
}

166
services/dao/worker.go Normal file
View File

@@ -0,0 +1,166 @@
package dao
import (
"fmt"
"github.com/VaalaCat/frp-panel/models"
)
func (q *queryImpl) CreateWorker(userInfo models.UserInfo, worker *models.Worker) error {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
worker.UserId = uint32(userInfo.GetUserID())
worker.TenantId = uint32(userInfo.GetTenantID())
worker.WorkerModel = nil
return db.Create(worker).Error
}
func (q *queryImpl) DeleteWorker(userInfo models.UserInfo, workerID string) error {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
return db.Unscoped().Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
ID: workerID,
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Delete(&models.Worker{}).Error
}
func (q *queryImpl) UpdateWorker(userInfo models.UserInfo, worker *models.Worker) error {
if worker.WorkerEntity == nil {
return fmt.Errorf("invalid worker entity")
}
if len(worker.WorkerEntity.ID) == 0 {
return fmt.Errorf("invalid worker id")
}
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
if err := db.Unscoped().Model(&models.Worker{
WorkerEntity: &models.WorkerEntity{
ID: worker.ID,
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Association("Clients").Unscoped().Clear(); err != nil {
return err
}
return db.Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
ID: worker.ID,
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Save(worker).Error
}
func (q *queryImpl) GetWorkerByWorkerID(userInfo models.UserInfo, workerID string) (*models.Worker, error) {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
w := &models.Worker{}
err := db.Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
ID: workerID,
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Preload("Clients").First(w).Error
if err != nil {
return nil, err
}
return w, nil
}
func (q *queryImpl) ListWorkers(userInfo models.UserInfo, page, pageSize int) ([]*models.Worker, error) {
if page < 1 || pageSize < 1 || pageSize > 100 {
return nil, fmt.Errorf("invalid page or page size")
}
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
offset := (page - 1) * pageSize
var workers []*models.Worker
err := db.Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Offset(offset).Limit(pageSize).Preload("Clients").Find(&workers).Error
if err != nil {
return nil, err
}
return workers, nil
}
func (q *queryImpl) AdminListWorkersByClientID(clientID string) ([]*models.Worker, error) {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
client, err := q.AdminGetClientByClientID(clientID)
if err != nil {
return nil, err
}
err = db.Model(&client).Preload("Workers").First(&client).Error
if err != nil {
return nil, err
}
return client.Workers, nil
}
func (q *queryImpl) ListWorkersWithKeyword(userInfo models.UserInfo, page, pageSize int, keyword string) ([]*models.Worker, error) {
if page < 1 || pageSize < 1 || len(keyword) == 0 || pageSize > 100 {
return nil, fmt.Errorf("invalid page or page size or keyword")
}
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
offset := (page - 1) * pageSize
var workers []*models.Worker
err := db.Where("name like ?", "%"+keyword+"%").
Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Offset(offset).Limit(pageSize).Preload("Clients").Find(&workers).Error
if err != nil {
return nil, err
}
return workers, nil
}
func (q *queryImpl) CountWorkers(userInfo models.UserInfo) (int64, error) {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
var count int64
err := db.Model(&models.Worker{}).Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (q *queryImpl) CountWorkersWithKeyword(userInfo models.UserInfo, keyword string) (int64, error) {
db := q.ctx.GetApp().GetDBManager().GetDefaultDB()
var count int64
err := db.Model(&models.Worker{}).Where("name like ?", "%"+keyword+"%").
Where(&models.Worker{
WorkerEntity: &models.WorkerEntity{
UserId: uint32(userInfo.GetUserID()),
TenantId: uint32(userInfo.GetTenantID()),
},
}).Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}

View File

@@ -10,6 +10,7 @@ import (
masterserver "github.com/VaalaCat/frp-panel/biz/master/server"
"github.com/VaalaCat/frp-panel/biz/master/shell"
"github.com/VaalaCat/frp-panel/biz/master/streamlog"
"github.com/VaalaCat/frp-panel/biz/master/worker"
"github.com/VaalaCat/frp-panel/conf"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
@@ -26,6 +27,21 @@ type server struct {
appInstance app.Application
}
// ListClientWorkers implements pb.MasterServer.
func (s *server) ListClientWorkers(ctx context.Context, req *pb.ListClientWorkersRequest) (*pb.ListClientWorkersResponse, error) {
logger.Logger(ctx).Infof("list client workers, clientID: [%+v]", req.GetBase().GetClientId())
appCtx := app.NewContext(ctx, s.appInstance)
if _, err := client.ValidateClientRequest(appCtx, req.GetBase()); err != nil {
logger.Logger(ctx).WithError(err).Errorf("cannot validate client request")
return nil, err
}
logger.Logger(appCtx).Infof("validate client success, clientID: [%+v]", req.GetBase().GetClientId())
return worker.ListClientWorkers(appCtx, req)
}
func newRpcServer(appInstance app.Application, creds credentials.TransportCredentials) *grpc.Server {
// s := grpc.NewServer(grpc.Creds(insecure.NewCredentials()))
// s := grpc.NewServer(grpc.Creds(creds))

48
services/port/manager.go Normal file
View File

@@ -0,0 +1,48 @@
package tunnel
import (
"context"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
)
type PortManager interface {
ClaimWorkerPort(c context.Context, workerID string) int32
GetWorkerPort(c context.Context, workerID string) (int32, bool)
}
type portManager struct {
portMap *utils.SyncMap[string, int32]
}
func (p *portManager) ClaimWorkerPort(c context.Context, workerID string) int32 {
port, err := utils.GetAvailablePort(defs.DefaultHostName)
if err != nil {
logger.Logger(c).WithError(err).Panic("get available port failed")
}
p.portMap.Store(workerID, int32(port))
return int32(port)
}
func (p *portManager) GetWorkerPort(c context.Context, workerID string) (int32, bool) {
return p.portMap.Load(workerID)
}
var (
mgr PortManager
)
func NewPortManager() PortManager {
return &portManager{
portMap: &utils.SyncMap[string, int32]{},
}
}
func GetPortManager() PortManager {
if mgr == nil {
mgr = NewPortManager()
}
return mgr
}

View File

@@ -0,0 +1,117 @@
//go:build !windows
package workerd
import (
"context"
"os"
"os/exec"
"syscall"
"time"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
)
type workerExecManager struct {
//用于外层循坏的退出
signMap *utils.SyncMap[string, bool]
//用于执行cancel函数
chanMap *utils.SyncMap[string, chan struct{}]
// 可执行文件路径
binaryPath string
// 默认参数
defaultArgs []string
}
// var ExecManager *execManager
func NewExecManager(binPath string, defaultArgs []string) app.WorkerExecManager {
if len(defaultArgs) == 0 {
defaultArgs = []string{"--watch", "--verbose"}
}
return &workerExecManager{
signMap: new(utils.SyncMap[string, bool]),
chanMap: new(utils.SyncMap[string, chan struct{}]),
binaryPath: binPath,
defaultArgs: defaultArgs,
}
}
func (m *workerExecManager) RunCmd(uid string, cwd string, argv []string) {
ctx := context.Background()
logger.Logger(context.Background()).Infof("start to run command, command id: [%s], argv: %s", uid, utils.MarshalForJson(argv))
if _, ok := m.chanMap.Load(uid); ok {
logger.Logger(ctx).Infof("command id: [%s] is already running, ignore", uid)
return
}
c := make(chan struct{})
m.chanMap.Store(uid, c)
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context, uid string, argv []string, m *workerExecManager) {
defer func(uid string, m *workerExecManager) {
m.signMap.Delete(uid)
}(uid, m)
logger.Logger(ctx).Infof("command id: [%s] is running!", uid)
for {
args := []string{}
args = append(args, m.defaultArgs...)
args = append(args, argv...)
cmd := exec.CommandContext(ctx, m.binaryPath, args...)
cmd.Dir = cwd
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: false}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logger.Logger(ctx).WithError(err).Errorf("command id: [%s] run failed, binary path: [%s], args: %s", uid, m.binaryPath, utils.MarshalForJson(args))
}
if exit, ok := m.signMap.Load(uid); ok && exit {
return
}
time.Sleep(3 * time.Second)
}
}(ctx, uid, argv, m)
go func(cancel context.CancelFunc, uid string, m *workerExecManager) {
defer func(uid string, m *workerExecManager) {
m.chanMap.Delete(uid)
}(uid, m)
if channel, ok := m.chanMap.Load(uid); ok {
<-channel
m.signMap.Store(uid, true)
cancel()
return
} else {
logger.Logger(ctx).Errorf("command id: [%s] is not running!", uid)
return
}
}(cancel, uid, m)
}
func (m *workerExecManager) ExitCmd(uid string) {
if channel, ok := m.chanMap.Load(uid); ok {
channel <- struct{}{}
}
}
func (m *workerExecManager) ExitAllCmd() {
for uid := range m.chanMap.ToMap() {
m.ExitCmd(uid)
}
}
func (m *workerExecManager) UpdateBinaryPath(path string) {
m.binaryPath = path
}

View File

@@ -0,0 +1,40 @@
//go:build windows
package workerd
import (
"context"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils/logger"
)
type workerExecManager struct{}
// ExitAllCmd implements app.WorkerExecManager.
func (w *workerExecManager) ExitAllCmd() {
ctx := context.Background()
logger.Logger(ctx).Errorf("windows has not implemented functions")
}
// ExitCmd implements app.WorkerExecManager.
func (w *workerExecManager) ExitCmd(workerId string) {
ctx := context.Background()
logger.Logger(ctx).Errorf("windows has not implemented functions")
}
// RunCmd implements app.WorkerExecManager.
func (w *workerExecManager) RunCmd(workerId string, cwd string, argv []string) {
ctx := context.Background()
logger.Logger(ctx).Errorf("windows has not implemented functions")
}
// UpdateBinaryPath implements app.WorkerExecManager.
func (w *workerExecManager) UpdateBinaryPath(path string) {
ctx := context.Background()
logger.Logger(ctx).Errorf("windows has not implemented functions")
}
func NewExecManager(binPath string, defaultArgs []string) app.WorkerExecManager {
return &workerExecManager{}
}

44
services/workerd/file.go Normal file
View File

@@ -0,0 +1,44 @@
package workerd
import (
"context"
"path/filepath"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/utils"
)
func WriteWorkerCodeToFile(ctx context.Context, worker *pb.Worker, workerdCWD string) error {
return utils.WriteFile(
CodeFilePath(ctx, worker, workerdCWD),
string(worker.GetCode()))
}
func CodeFilePath(ctx context.Context, worker *pb.Worker, workerdCWD string) string {
return filepath.Join(
WorkerCWDPath(ctx, worker, workerdCWD),
defs.WorkerCodePath,
worker.GetCodeEntry())
}
func WorkerCodeRootPath(ctx context.Context, worker *pb.Worker, workerdCWD string) string {
return filepath.Join(
WorkerCWDPath(ctx, worker, workerdCWD),
defs.WorkerCodePath)
}
func WorkerCWDPath(ctx context.Context, worker *pb.Worker, workerdCWD string) string {
return filepath.Join(
workerdCWD,
defs.WorkerInfoPath,
worker.GetWorkerId(),
)
}
func ConfigFilePath(ctx context.Context, worker *pb.Worker, workerdCWD string) string {
return filepath.Join(
WorkerCWDPath(ctx, worker, workerdCWD),
defs.CapFileName,
)
}

View File

@@ -0,0 +1,49 @@
package workerd
import (
"fmt"
"strings"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/utils"
"github.com/samber/lo"
)
type Opt func(*pb.Worker)
func FillWorkerValue(worker *pb.Worker, UserID uint, opt ...Opt) {
worker.UserId = lo.ToPtr(uint32(UserID))
if len(worker.GetName()) == 0 {
worker.Name = lo.ToPtr(utils.NewCodeName(2))
}
if len(worker.GetCode()) == 0 {
worker.Code = lo.ToPtr(string(defs.DefaultCode))
}
if len(worker.GetWorkerId()) == 0 {
worker.WorkerId = lo.ToPtr(utils.GenerateUUID())
}
if len(worker.GetCodeEntry()) == 0 {
worker.CodeEntry = lo.ToPtr(string(defs.DefaultEntry))
}
if len(worker.GetConfigTemplate()) == 0 {
worker.ConfigTemplate = lo.ToPtr(string(defs.DefaultConfigTemplate))
}
worker.Socket = &pb.Socket{
Name: lo.ToPtr(worker.GetWorkerId()),
Address: lo.ToPtr(fmt.Sprintf(defs.DefaultSocketTemplate, worker.GetWorkerId())),
}
for _, o := range opt {
o(worker)
}
}
func SafeWorkerID(id string) string {
replacer := strings.NewReplacer("/", "", ".", "", "-", "")
return replacer.Replace(id)
}

View File

@@ -0,0 +1,93 @@
package workerd
import (
"context"
"os"
"strings"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils/logger"
)
var _ app.WorkerController = (*workerdController)(nil)
type workerdController struct {
worker *pb.Worker
workerdCwd string
status *defs.WorkerStatus
}
func NewWorkerdController(worker *pb.Worker, workerdCwd string) *workerdController {
return &workerdController{
worker: worker,
workerdCwd: workerdCwd,
}
}
func (w *workerdController) RunWorker(c *app.Context) {
if err := w.Init(c); err != nil {
logger.Logger(c).WithError(err).Errorf("init worker failed, workerId: [%s]", w.worker.GetWorkerId())
return
}
execMgr := c.GetApp().GetWorkerExecManager()
execMgr.RunCmd(
w.worker.GetWorkerId(), WorkerCWDPath(c, w.worker, w.workerdCwd),
[]string{ConfigFilePath(c, w.worker, w.workerdCwd)},
)
}
func (w *workerdController) StopWorker(c *app.Context) {
execMgr := c.GetApp().GetWorkerExecManager()
execMgr.ExitCmd(w.worker.GetWorkerId())
w.GarbageCollect()
}
func (w *workerdController) GetWorkerStatus(c *app.Context) defs.WorkerStatus {
if w.status == nil {
return defs.WorkerStatus_Unknown
}
return *w.status
}
func (w *workerdController) Init(c *app.Context) error {
workerCodePath := WorkerCodeRootPath(c, w.worker, w.workerdCwd)
// 1. 创建工作目录
if err := os.MkdirAll(workerCodePath, os.ModePerm); err != nil {
logger.Logger(c).WithError(err).Errorf("create work dir failed, path: [%s]", workerCodePath)
return err
}
// 2. 写入配置文件和代码文件
if err := WriteWorkerCodeToFile(c, w.worker, w.workerdCwd); err != nil {
logger.Logger(c).WithError(err).Errorf("write worker code failed, workerId: [%s]", w.worker.GetWorkerId())
return err
}
if err := GenCapnpConfig(c, w.workerdCwd, &pb.WorkerList{Workers: []*pb.Worker{w.worker}}); err != nil {
logger.Logger(c).WithError(err).Errorf("gen worker capnp config failed, workerId: [%s]", w.worker.GetWorkerId())
return err
}
logger.Logger(c).Infof("init worker success, workerId: [%s], code path: [%s]", w.worker.GetWorkerId(), workerCodePath)
return nil
}
func (w *workerdController) GarbageCollect() {
ctx := context.Background()
pathToRemove := WorkerCWDPath(ctx, w.worker, w.workerdCwd)
if !strings.HasPrefix(pathToRemove, "/tmp") {
logger.Logger(ctx).Errorf("path not start with /tmp, do not remove path: [%s]", pathToRemove)
return
}
if err := os.RemoveAll(pathToRemove); err != nil {
logger.Logger(ctx).WithError(err).Errorf("remove path failed, path: [%s]", pathToRemove)
}
}

View File

@@ -0,0 +1,67 @@
package workerd
import (
"bytes"
"errors"
"html/template"
"path/filepath"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/utils"
"github.com/samber/lo"
)
func BuildCapfile(workers []*pb.Worker) map[string]string {
if len(workers) == 0 {
return map[string]string{}
}
results := map[string]string{}
for _, worker := range workers {
tmpWorker := &pb.Worker{
WorkerId: lo.ToPtr(SafeWorkerID(worker.GetWorkerId())),
UserId: lo.ToPtr(worker.GetUserId()),
CodeEntry: lo.ToPtr(worker.GetCodeEntry()),
Socket: &pb.Socket{
Name: lo.ToPtr(worker.GetWorkerId()),
Address: lo.ToPtr(worker.GetSocket().GetAddress()),
},
ConfigTemplate: lo.ToPtr(worker.GetConfigTemplate()),
}
writer := new(bytes.Buffer)
capTemplate := template.New("capfile")
workerTemplate := tmpWorker.GetConfigTemplate()
if workerTemplate == "" {
workerTemplate = defs.DefaultConfigTemplate
}
capTemplate, err := capTemplate.Parse(workerTemplate)
if err != nil {
panic(err)
}
capTemplate.Execute(writer, tmpWorker)
results[worker.GetWorkerId()] = writer.String()
}
return results
}
func GenWorkerConfig(worker *pb.Worker, dir string) error {
if worker == nil || worker.GetWorkerId() == "" {
return errors.New("error worker")
}
fileMap := BuildCapfile([]*pb.Worker{worker})
fileContent, ok := fileMap[worker.GetWorkerId()]
if !ok {
return errors.New("BuildCapfile error")
}
return utils.WriteFile(
filepath.Join(
dir, defs.WorkerInfoPath,
worker.GetWorkerId(), defs.CapFileName,
), fileContent)
}

View File

@@ -0,0 +1,37 @@
package workerd
import (
"context"
"errors"
"github.com/VaalaCat/frp-panel/pb"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
func GenCapnpConfig(ctx context.Context, workerdDir string, workerList *pb.WorkerList) error {
var hasError bool
for _, worker := range workerList.Workers {
fileMap := BuildCapfile([]*pb.Worker{worker})
if fileContent, ok := fileMap[worker.GetWorkerId()]; ok {
err := utils.WriteFile(
ConfigFilePath(ctx, worker, workerdDir),
fileContent)
if err != nil {
logrus.WithError(err).Errorf("failed to write file, worker is: %+v", worker.Name)
hasError = true
}
}
}
logger.Logger(ctx).Infof("GenCapnpConfig has error: %v, workerList: %+v", hasError,
lo.SliceToMap(workerList.GetWorkers(), func(w *pb.Worker) (string, bool) { return w.GetWorkerId(), true }))
if hasError {
return errors.New("GenCapnpConfig has error")
}
return nil
}

View File

@@ -0,0 +1,93 @@
package workerd
import (
"testing"
"github.com/VaalaCat/frp-panel/pb"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestBuildCapfile(t *testing.T) {
tests := []struct {
name string
wokers []*pb.Worker
expect func(t *testing.T, resp map[string]string)
}{
{
name: "common case",
wokers: []*pb.Worker{
{
WorkerId: lo.ToPtr("test"),
CodeEntry: lo.ToPtr("test/entry.js"),
Socket: &pb.Socket{
Address: lo.ToPtr("unix:/test/test.sock"),
},
},
{
WorkerId: lo.ToPtr("test1"),
CodeEntry: lo.ToPtr("test1/entry.js"),
Socket: &pb.Socket{
Address: lo.ToPtr("unix:/test1/test.sock"),
},
},
},
expect: func(t *testing.T, result map[string]string) {
assert.Equal(t,
`using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "test", worker = .vtestWorker),
],
sockets = [
(
name = "test",
address = "unix:/test/test.sock",
http=(),
service="test"
),
]
);
const vtestWorker :Workerd.Worker = (
modules = [
(name = "test/entry.js", esModule = embed "src/test/entry.js"),
],
compatibilityDate = "2023-04-03",
);`, result["test"])
assert.Equal(t, `using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "test1", worker = .vtest1Worker),
],
sockets = [
(
name = "test1",
address = "unix:/test1/test.sock",
http=(),
service="test1"
),
]
);
const vtest1Worker :Workerd.Worker = (
modules = [
(name = "test1/entry.js", esModule = embed "src/test1/entry.js"),
],
compatibilityDate = "2023-04-03",
);`, result["test1"])
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.expect(t, BuildCapfile(tt.wokers))
})
}
}

View File

@@ -0,0 +1,43 @@
package workerd
import (
"context"
"testing"
"time"
"github.com/VaalaCat/frp-panel/pb"
"github.com/sourcegraph/conc"
)
func TestRunWorker(t *testing.T) {
workerdCWD := "/home/coder/code/frp-panel/tmp/workerd"
workerID := "test"
workerdBinPath := "/home/coder/go/bin/workerd"
c := context.Background()
defaultWorker := &pb.Worker{WorkerId: &workerID}
FillWorkerValue(defaultWorker, 1)
if err := GenCapnpConfig(c, workerdCWD, &pb.WorkerList{Workers: []*pb.Worker{defaultWorker}}); err != nil {
panic(err)
}
var wg conc.WaitGroup
wg.Go(func() {
time.Sleep(10 * time.Second)
})
if err := WriteWorkerCodeToFile(c, defaultWorker, workerdCWD); err != nil {
panic(err)
}
runner := NewExecManager(workerdBinPath,
[]string{"serve", "--watch", "--verbose"})
runner.RunCmd(workerID, WorkerCWDPath(c, defaultWorker, workerdCWD),
[]string{ConfigFilePath(c, defaultWorker, workerdCWD)})
defer runner.ExitAllCmd()
wg.Wait()
}

View File

@@ -0,0 +1,126 @@
package workerd
import (
"fmt"
"runtime"
"github.com/VaalaCat/frp-panel/defs"
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
)
type workersManager struct {
workers *utils.SyncMap[string, app.WorkerController]
}
func NewWorkersManager() *workersManager {
return &workersManager{
workers: &utils.SyncMap[string, app.WorkerController]{},
}
}
func (m *workersManager) GetWorker(ctx *app.Context, id string) (app.WorkerController, bool) {
return m.workers.Load(id)
}
func (m *workersManager) RunWorker(ctx *app.Context, id string, worker app.WorkerController) error {
if !ctx.GetApp().GetConfig().Client.Features.EnableFunctions {
logger.Logger(ctx).Errorf("function features are not enabled")
return fmt.Errorf("function features are not enabled")
}
worker.RunWorker(ctx)
m.workers.Store(id, worker)
return nil
}
func (m *workersManager) StopWorker(ctx *app.Context, id string) error {
worker, ok := m.workers.Load(id)
if !ok {
return fmt.Errorf("cannot find worker, id: %s", id)
}
worker.StopWorker(ctx)
m.workers.Delete(id)
return nil
}
func (m *workersManager) StopAll() {
m.workers.Range(func(k string, v app.WorkerController) bool {
v.StopWorker(nil)
return true
})
tmpM := m.workers.ToMap()
for k := range tmpM {
m.workers.Delete(k)
}
}
func (m *workersManager) GetWorkerStatus(ctx *app.Context, id string) (defs.WorkerStatus, error) {
ok, err := utils.ProcessExistsBySelf(id)
if err != nil {
return defs.WorkerStatus_Unknown, err
}
if ok {
return defs.WorkerStatus_Running, nil
}
return defs.WorkerStatus_Inactive, nil
}
func (m *workersManager) InstallWorkerd(ctx *app.Context, url string, installDir string) (string, error) {
arch := runtime.GOARCH
os := runtime.GOOS
workerDownloadCfg := ctx.GetApp().GetConfig().Client.Worker.WorkerdDownloadURL
if os != "linux" {
return "", fmt.Errorf("unsupported os: %s", os)
}
if arch != "amd64" && arch != "arm64" {
return "", fmt.Errorf("unsupported arch: %s", arch)
}
downloadUrl := ""
if len(url) > 0 {
downloadUrl = url
} else {
switch arch {
case "amd64":
downloadUrl = workerDownloadCfg.LinuxX8664
case "arm64":
downloadUrl = workerDownloadCfg.LinuxArm64
default:
return "", fmt.Errorf("unsupported arch: %s", arch)
}
}
if workerDownloadCfg.UseProxy {
if len(ctx.GetApp().GetConfig().App.GithubProxyUrl) > 0 {
downloadUrl = fmt.Sprintf("%s/%s", ctx.GetApp().GetConfig().App.GithubProxyUrl, downloadUrl)
}
}
proxyUrl := ctx.GetApp().GetConfig().HTTP_PROXY
path, err := utils.DownloadFile(ctx, downloadUrl, proxyUrl)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("failed to download workerd, url: %s", downloadUrl)
return "", err
}
if len(installDir) == 0 {
installDir = "/usr/local/bin"
}
finalPath, err := utils.ExtractGZTo(path, "workerd", installDir)
if err != nil {
logger.Logger(ctx).WithError(err).Errorf("failed to extract workerd, path: %s", path)
return "", err
}
logger.Logger(ctx).Infof("workerd installed successfully, path: %s", finalPath)
return finalPath, nil
}

24
utils/addr.go Normal file
View File

@@ -0,0 +1,24 @@
package utils
import (
"fmt"
"strings"
)
func NodeHostPrefix(nodeName, nodeID string) string {
return fmt.Sprintf("%s%s", nodeName, nodeID)
}
func NodeHost(nodeName, nodeID string, domainSuffix string) string {
suffix := strings.Trim(domainSuffix, ".")
return fmt.Sprintf("%s.%s", NodeHostPrefix(nodeName, nodeID), suffix)
}
func WorkerHostPrefix(workerName string) string {
return workerName
}
func WorkerHost(workerName, domainSuffix string) string {
suffix := strings.Trim(domainSuffix, ".")
return fmt.Sprintf("%s.%s", WorkerHostPrefix(workerName), suffix)
}

8
utils/codename.go Normal file
View File

@@ -0,0 +1,8 @@
package utils
import "github.com/lucasepe/codename"
func NewCodeName(tokenLength int) string {
rng, _ := codename.DefaultRNG()
return codename.Generate(rng, tokenLength)
}

134
utils/file.go Normal file
View File

@@ -0,0 +1,134 @@
package utils
import (
"archive/tar"
"archive/zip"
"bytes"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
)
const (
httpFileMaxBytes = 100 * (1 << 20) // 50 MB
)
func WriteFile(path string, content string) error {
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
_, err = f.WriteString(content)
if err != nil {
return err
}
return nil
}
func CreateTarFromZip(zipReader *zip.Reader) ([]byte, error) {
var tarBuffer bytes.Buffer
err := writeTarArchive(&tarBuffer, zipReader)
if err != nil {
return nil, err
}
return tarBuffer.Bytes(), nil
}
func writeTarArchive(w io.Writer, zipReader *zip.Reader) error {
tarWriter := tar.NewWriter(w)
defer tarWriter.Close()
for _, file := range zipReader.File {
err := processFileInZipArchive(file, tarWriter)
if err != nil {
return err
}
}
return nil
}
func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error {
fileReader, err := file.Open()
if err != nil {
return err
}
defer fileReader.Close()
err = tarWriter.WriteHeader(&tar.Header{
Name: file.Name,
Size: file.FileInfo().Size(),
Mode: int64(file.Mode()),
ModTime: file.Modified,
// Note: Zip archives do not store ownership information.
Uid: 1000,
Gid: 1000,
})
if err != nil {
return err
}
n, err := io.CopyN(tarWriter, fileReader, httpFileMaxBytes)
log.Println(file.Name, n, err)
if errors.Is(err, io.EOF) {
err = nil
}
return err
}
func CreateZipFromTar(tarReader *tar.Reader) ([]byte, error) {
var zipBuffer bytes.Buffer
err := WriteZipArchive(&zipBuffer, tarReader)
if err != nil {
return nil, err
}
return zipBuffer.Bytes(), nil
}
func WriteZipArchive(w io.Writer, tarReader *tar.Reader) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
for {
tarHeader, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
zipHeader, err := zip.FileInfoHeader(tarHeader.FileInfo())
if err != nil {
return err
}
zipHeader.Name = tarHeader.Name
// Some versions of unzip do not check the mode on a file entry and
// simply assume that entries with a trailing path separator (/) are
// directories, and that everything else is a file. Give them a hint.
if tarHeader.FileInfo().IsDir() && !strings.HasSuffix(tarHeader.Name, "/") {
zipHeader.Name += "/"
}
zipEntry, err := zipWriter.CreateHeader(zipHeader)
if err != nil {
return err
}
_, err = io.CopyN(zipEntry, tarReader, httpFileMaxBytes)
if errors.Is(err, io.EOF) {
err = nil
}
if err != nil {
return err
}
}
return nil // don't need to flush as we call `writer.Close()`
}

View File

@@ -1,8 +1,18 @@
package utils
import (
"compress/gzip"
"context"
"fmt"
"io"
"math/rand"
"os"
"path"
"path/filepath"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/imroc/req/v3"
"go.uber.org/multierr"
)
func EnsureDirectoryExists(filePath string) error {
@@ -16,3 +26,171 @@ func EnsureDirectoryExists(filePath string) error {
}
return nil
}
func FindExecutableNames(filter func(name string) bool, extraPaths ...string) ([]string, error) {
pathEnv := os.Getenv("PATH")
if pathEnv == "" {
return nil, fmt.Errorf("PATH environment variable is empty")
}
var results []string
seen := make(map[string]struct{})
var errs error
pathToCheck := extraPaths
pathToCheck = append(pathToCheck, filepath.SplitList(pathEnv)...)
for _, dir := range pathToCheck {
entries, err := os.ReadDir(dir)
if err != nil {
// cannot read this directory at all: skip it silently
continue
}
for _, entry := range entries {
name := entry.Name()
if _, dup := seen[name]; dup {
continue
}
if !filter(name) {
continue
}
// We've got a candidate name; try to stat it
info, err := entry.Info()
if err != nil {
// record the error for this matching name
errs = multierr.Append(errs, err)
continue
}
// skip directories or nonexecutable
if info.IsDir() || info.Mode()&0111 == 0 {
continue
}
results = append(results, path.Join(dir, name))
seen[name] = struct{}{}
}
}
if len(results) > 0 {
return results, nil
}
if errs != nil {
// return only the aggregated errors
return nil, errs
}
// no matches and no filespecific errors
return nil, nil
}
var TmpFileDir = path.Join(os.TempDir(), "vaala-frp-panel-download")
// DownloadFile 下载文件到一个临时文件,返回临时文件路径
func DownloadFile(ctx context.Context, url string, proxyUrl string) (string, error) {
os.MkdirAll(TmpFileDir, 0777)
tmpPath, err := os.MkdirTemp(TmpFileDir, "downloads")
if err != nil {
return "", err
}
tmpFileName := generateRandomFileName("download", ".tmp")
fileFullPath := path.Join(tmpPath, tmpFileName)
cli := req.C()
if len(proxyUrl) > 0 {
cli = cli.SetProxyURL(proxyUrl)
}
err = cli.NewParallelDownload(url).
SetConcurrency(5).
SetSegmentSize(1024 * 1024 * 1).
SetOutputFile(fileFullPath).
SetFileMode(0777).
SetTempRootDir(path.Join(TmpFileDir, "downloads_cache")).
Do()
if err != nil {
logger.Logger(ctx).WithError(err).Error("download file from url error")
return "", err
}
return fileFullPath, nil
}
// generateRandomFileName 生成一个随机文件名
func generateRandomFileName(prefix, extension string) string {
randomStr := randomString(8)
fileName := fmt.Sprintf("%s_%s%s", prefix, randomStr, extension)
return fileName
}
// randomString 生成一个指定长度的随机字符串
func randomString(length int) string {
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
bytes := make([]byte, length)
for i := range bytes {
bytes[i] = charset[rand.Intn(len(charset))]
}
return string(bytes)
}
// ExtractGZTo decompresses the srcGZ file into a temporary directory,
// renames the extracted file to newName, moves it to destDir, and sets executable permissions (0755).
// It returns the full path of the final file on success.
func ExtractGZTo(srcGZ, newName, destDir string) (string, error) {
// 1. Open source .gz file
f, err := os.Open(srcGZ)
if err != nil {
return "", fmt.Errorf("failed to open source gzip file %q: %w", srcGZ, err)
}
defer f.Close()
// 2. Create gzip reader
zr, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader for %q: %w", srcGZ, err)
}
defer zr.Close()
// 3. Create temporary directory
tmpDir, err := os.MkdirTemp("", "vaala-frp-panel-gz_extract_*")
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}
// Note: tmpDir is not auto-deleted. Caller may clean up if desired.
// 4. Create the output file in the temp directory with the new name
tmpFilePath := filepath.Join(tmpDir, newName)
outFile, err := os.Create(tmpFilePath)
if err != nil {
return "", fmt.Errorf("failed to create temp file %q: %w", tmpFilePath, err)
}
defer outFile.Close()
// 5. Decompress into temp file
if _, err := io.Copy(outFile, zr); err != nil {
return "", fmt.Errorf("failed to write decompressed data to %q: %w", tmpFilePath, err)
}
// 6. Ensure destination directory exists
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("failed to create destination directory %q: %w", destDir, err)
}
// 7. Move the file to the destination directory
finalPath := filepath.Join(destDir, newName)
if err := os.Rename(tmpFilePath, finalPath); err != nil {
return "", fmt.Errorf("failed to move file to %q: %w", finalPath, err)
}
// 8. Set executable permission
if err := os.Chmod(finalPath, 0755); err != nil {
return "", fmt.Errorf("failed to set executable permission on %q: %w", finalPath, err)
}
return finalPath, nil
}

52
utils/port.go Normal file
View File

@@ -0,0 +1,52 @@
package utils
import (
"fmt"
"net"
"time"
"github.com/sirupsen/logrus"
)
func GetAvailablePort(addr string) (int, error) {
address, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:0", addr))
if err != nil {
return 0, err
}
listener, err := net.ListenTCP("tcp", address)
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}
func IsPortAvailable(port int, addr string) bool {
address := fmt.Sprintf("%s:%d", addr, port)
listener, err := net.Listen("tcp", address)
if err != nil {
logrus.Infof("port %s is taken: %s", address, err)
return false
}
defer listener.Close()
return true
}
func WaitForPort(host string, port int) {
for {
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
if err == nil {
conn.Close()
break
}
logrus.Warnf("Target port %s:%d is not open yet, waiting...\n", host, port)
time.Sleep(time.Second * 5)
}
logrus.Infof("Target port %s:%d is open", host, port)
time.Sleep(time.Second * 1)
}

32
utils/process.go Normal file
View File

@@ -0,0 +1,32 @@
package utils
import (
"os"
"strings"
"github.com/shirou/gopsutil/v4/process"
)
func ProcessExistsBySelf(target string) (bool, error) {
selfPID := int32(os.Getpid())
procs, err := process.Processes()
if err != nil {
return false, err
}
for _, p := range procs {
ppid, err := p.Ppid()
if err != nil || ppid != selfPID {
continue
}
cmdline, err := p.Cmdline()
if err != nil {
continue
}
if strings.Contains(cmdline, target) {
return true, nil
}
}
return false, nil
}

View File

@@ -180,3 +180,16 @@ func (s *SyncMap[K, V]) Values() []V {
return values
}
func (s *SyncMap[K, V]) ToMap() map[K]V {
s.mu.Lock()
defer s.mu.Unlock()
var m = make(map[K]V, len(s.m))
for k, v := range s.m {
m[k] = v
}
return m
}

15
utils/uuid.go Normal file
View File

@@ -0,0 +1,15 @@
package utils
import (
"strings"
"github.com/google/uuid"
)
func GenerateUUIDWithoutSeperator() string {
return strings.Replace(uuid.New().String(), "-", "", -1)
}
func GenerateUUID() string {
return uuid.New().String()
}

69
www/api/worker.ts Normal file
View File

@@ -0,0 +1,69 @@
import http from '@/api/http'
import { API_PATH } from '@/lib/consts'
import {
CreateWorkerIngressRequest,
CreateWorkerIngressResponse,
CreateWorkerRequest,
CreateWorkerResponse,
GetWorkerIngressRequest,
GetWorkerIngressResponse,
GetWorkerRequest,
GetWorkerResponse,
GetWorkerStatusRequest,
GetWorkerStatusResponse,
InstallWorkerdRequest,
InstallWorkerdResponse,
ListWorkersRequest,
ListWorkersResponse,
RemoveWorkerRequest,
RemoveWorkerResponse,
UpdateWorkerRequest,
UpdateWorkerResponse,
} from '@/lib/pb/api_client'
import { BaseResponse } from '@/types/api'
import { constants } from 'node:buffer'
export const getWorker = async (req: GetWorkerRequest) => {
const res = await http.post(API_PATH + '/worker/get', GetWorkerRequest.toJson(req))
return GetWorkerResponse.fromJson((res.data as BaseResponse).body)
}
export const createWorker = async (req: CreateWorkerRequest) => {
const res = await http.post(API_PATH + '/worker/create', CreateWorkerRequest.toJson(req))
return CreateWorkerResponse.fromJson((res.data as BaseResponse).body)
}
export const updateWorker = async (req: UpdateWorkerRequest) => {
const res = await http.post(API_PATH + '/worker/update', UpdateWorkerRequest.toJson(req))
return UpdateWorkerResponse.fromJson((res.data as BaseResponse).body)
}
export const removeWorker = async (req: RemoveWorkerRequest) => {
const res = await http.post(API_PATH + '/worker/remove', RemoveWorkerRequest.toJson(req))
return RemoveWorkerResponse.fromJson((res.data as BaseResponse).body)
}
export const listWorkers = async (req: ListWorkersRequest) => {
const res = await http.post(API_PATH + '/worker/list', ListWorkersRequest.toJson(req))
return ListWorkersResponse.fromJson((res.data as BaseResponse).body)
}
export const createWorkerIngress = async (req: CreateWorkerIngressRequest) => {
const res = await http.post(API_PATH + '/worker/create_ingress', CreateWorkerIngressRequest.toJson(req))
return CreateWorkerIngressResponse.fromJson((res.data as BaseResponse).body)
}
export const getWorkerIngress = async (req: GetWorkerIngressRequest) => {
const res = await http.post(API_PATH + '/worker/get_ingress', GetWorkerIngressRequest.toJson(req))
return GetWorkerIngressResponse.fromJson((res.data as BaseResponse).body)
}
export const getWorkerStatus = async (req: GetWorkerStatusRequest) => {
const res = await http.post(API_PATH + '/worker/status', GetWorkerStatusRequest.toJson(req))
return GetWorkerStatusResponse.fromJson((res.data as BaseResponse).body)
}
export const installWorkerd = async (req: InstallWorkerdRequest) => {
const res = await http.post(API_PATH + '/client/install_workerd', InstallWorkerdRequest.toJson(req))
return InstallWorkerdResponse.fromJson((res.data as BaseResponse).body)
}

View File

@@ -1,24 +1,24 @@
"use client"
'use client'
import React from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { listClient } from '@/api/client'
import { Combobox } from './combobox'
import { useTranslation } from 'react-i18next'
import { Client } from '@/lib/pb/common'
export interface ClientSelectorProps {
clientID?: string
setClientID: (clientID: string) => void
clients?: Client[]
onOpenChange?: () => void
}
export const ClientSelector: React.FC<ClientSelectorProps> = ({
clientID,
setClientID,
onOpenChange
}) => {
export const ClientSelector: React.FC<ClientSelectorProps> = ({ clientID, setClientID, clients, onOpenChange }) => {
const { t } = useTranslation()
const handleClientChange = (value: string) => { setClientID(value) }
const handleClientChange = (value: string) => {
setClientID(value)
}
const [keyword, setKeyword] = React.useState('')
const { data: clientList, refetch: refetchClients } = useQuery({
@@ -27,15 +27,23 @@ export const ClientSelector: React.FC<ClientSelectorProps> = ({
return listClient({ page: 1, pageSize: 8, keyword: keyword })
},
placeholderData: keepPreviousData,
enabled: clients === undefined,
})
return (
<Combobox
placeholder={t('selector.client.placeholder')}
dataList={clientList?.clients.map((client) => ({
dataList={
clients !== undefined
? clients.map((client) => ({
value: client.id || '',
label: client.id || ''
})) || []}
label: client.id || '',
}))
: clientList?.clients.map((client) => ({
value: client.id || '',
label: client.id || '',
})) || []
}
setValue={handleClientChange}
value={clientID}
onKeyWordChange={setKeyword}

View File

@@ -1,11 +1,11 @@
"use client"
'use client'
import { Popover, PopoverTrigger } from "@radix-ui/react-popover"
import { Badge } from "../ui/badge"
import { ClientStatus } from "@/lib/pb/api_master"
import { PopoverContent } from "../ui/popover"
import { useTranslation } from "react-i18next"
import { motion } from "framer-motion"
import { Popover, PopoverTrigger } from '@radix-ui/react-popover'
import { Badge } from '../ui/badge'
import { ClientStatus } from '@/lib/pb/api_master'
import { PopoverContent } from '../ui/popover'
import { useTranslation } from 'react-i18next'
import { motion } from 'framer-motion'
import { formatDistanceToNow } from 'date-fns'
import { zhCN, enUS } from 'date-fns/locale'
@@ -13,31 +13,26 @@ export const ClientDetail = ({ clientStatus }: { clientStatus: ClientStatus }) =
const { t, i18n } = useTranslation()
const locale = i18n.language === 'zh' ? zhCN : enUS
const connectTime = clientStatus.connectTime ?
formatDistanceToNow(new Date(parseInt(clientStatus.connectTime.toString())), {
const connectTime = clientStatus.connectTime
? formatDistanceToNow(new Date(parseInt(clientStatus.connectTime.toString())), {
addSuffix: true,
locale
}) : '-'
locale,
})
: '-'
return (
<Popover>
<PopoverTrigger className='flex items-center'>
<PopoverTrigger className="flex items-center">
<Badge
variant="secondary"
className='text-nowrap rounded-full h-6 hover:bg-secondary/80 transition-colors text-sm'
className="text-nowrap rounded-full h-6 hover:bg-secondary/80 transition-colors text-xs"
>
{clientStatus.version?.gitVersion || 'Unknown'}
</Badge>
</PopoverTrigger>
<PopoverContent className="w-72 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-border">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-base font-semibold mb-3 text-center text-foreground">
{t('client.detail.title')}
</h3>
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }}>
<h3 className="text-base font-semibold mb-3 text-center text-foreground">{t('client.detail.title')}</h3>
<div className="space-y-2">
<div className="flex justify-between items-center py-1 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.version')}</span>

View File

@@ -0,0 +1,26 @@
'use client'
import React, { useLayoutEffect } from 'react'
import Editor, { loader } from '@monaco-editor/react'
loader.config({
paths: {
vs: 'https://fastly.jsdelivr.net/npm/monaco-editor@0.36.1/min/vs',
},
})
export interface WorkerEditorProps {
code: string
onChange: (value: string) => void
}
export function WorkerEditor({ code, onChange }: WorkerEditorProps) {
// 强制客户端渲染
useLayoutEffect(() => {}, [])
return (
<div className="h-full">
<Editor height="100%" defaultLanguage="javascript" value={code} onChange={(v) => onChange(v ?? '')} />
</div>
)
}

View File

@@ -146,7 +146,7 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({
const [pluginConfig, setPluginConfig] = useState<TypedClientPluginOptions | undefined>(defaultConfig.plugin)
const onSubmit = async (values: z.infer<typeof TCPConfigSchema>) => {
const cfgToSubmit = { ...values, plugin: pluginConfig, type: 'tcp', name: proxyName } as TCPProxyConfig
const cfgToSubmit = { ...defaultConfig, ...values, plugin: pluginConfig, type: 'tcp', name: proxyName } as TCPProxyConfig
if (!TypedProxyConfigValid(cfgToSubmit)) {
toast.error('Invalid configuration')
return
@@ -252,7 +252,7 @@ export const STCPProxyForm: React.FC<ProxyFormProps> = ({
const [pluginConfig, setPluginConfig] = useState<TypedClientPluginOptions | undefined>(defaultConfig.plugin)
const onSubmit = async (values: z.infer<typeof STCPConfigSchema>) => {
const cfgToSubmit = { ...values, plugin: pluginConfig, type: 'stcp', name: proxyName } as STCPProxyConfig
const cfgToSubmit = { ...defaultConfig, ...values, plugin: pluginConfig, type: 'stcp', name: proxyName } as STCPProxyConfig
if (!TypedProxyConfigValid(cfgToSubmit)) {
toast.error('Invalid configuration')
return
@@ -334,7 +334,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({
const [pluginConfig, setPluginConfig] = useState<TypedClientPluginOptions | undefined>(defaultConfig.plugin)
const onSubmit = async (values: z.infer<typeof UDPConfigSchema>) => {
const cfgToSubmit = { ...values, plugin: pluginConfig, type: 'udp', name: proxyName } as UDPProxyConfig
const cfgToSubmit = { ...defaultConfig, ...values, plugin: pluginConfig, type: 'udp', name: proxyName } as UDPProxyConfig
if (!TypedProxyConfigValid(cfgToSubmit)) {
toast.error('Invalid configuration')
return
@@ -442,7 +442,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({
const [pluginConfig, setPluginConfig] = useState<TypedClientPluginOptions | undefined>(defaultConfig.plugin)
const onSubmit = async (values: z.infer<typeof HTTPConfigSchema>) => {
const cfgToSubmit = { ...values, plugin: pluginConfig, type: 'http', name: proxyName } as HTTPProxyConfig
const cfgToSubmit = { ...defaultConfig, ...values, plugin: pluginConfig, type: 'http', name: proxyName } as HTTPProxyConfig
if (!TypedProxyConfigValid(cfgToSubmit)) {
toast.error('Invalid configuration')
return

View File

@@ -1,4 +1,4 @@
"use client"
'use client'
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
@@ -33,6 +33,7 @@ export type ProxyConfigMutateDialogProps = {
defaultProxyConfig?: TypedProxyConfig
defaultOriginalProxyConfig?: ProxyConfig
disableChangeProxyName?: boolean
onSuccess?: () => void
}
export const ProxyConfigMutateDialog = ({ ...props }: ProxyConfigMutateDialogProps) => {
@@ -41,16 +42,14 @@ export const ProxyConfigMutateDialog = ({ ...props }: ProxyConfigMutateDialogPro
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className='w-fit'>
<Button variant="outline" className="w-fit">
{t('proxy.config.create')}
</Button>
</DialogTrigger>
<DialogContent className='max-h-screen overflow-auto'>
<DialogContent className="max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{t('proxy.config.create_proxy')}</DialogTitle>
<DialogDescription>
{t('proxy.config.create_proxy_description')}
</DialogDescription>
<DialogDescription>{t('proxy.config.create_proxy_description')}</DialogDescription>
</DialogHeader>
<ProxyConfigMutateForm {...props} />
</DialogContent>
@@ -58,7 +57,13 @@ export const ProxyConfigMutateDialog = ({ ...props }: ProxyConfigMutateDialogPro
)
}
export const ProxyConfigMutateForm = ({ overwrite, defaultProxyConfig, defaultOriginalProxyConfig, disableChangeProxyName }: ProxyConfigMutateDialogProps) => {
export const ProxyConfigMutateForm = ({
overwrite,
defaultProxyConfig,
defaultOriginalProxyConfig,
disableChangeProxyName,
onSuccess,
}: ProxyConfigMutateDialogProps) => {
const { t } = useTranslation()
const [newClientID, setNewClientID] = useState<string | undefined>()
const [newServerID, setNewServerID] = useState<string | undefined>()
@@ -66,28 +71,30 @@ export const ProxyConfigMutateForm = ({ overwrite, defaultProxyConfig, defaultOr
const [proxyName, setProxyName] = useState<string | undefined>('')
const [proxyType, setProxyType] = useState<ProxyType>('http')
const [selectedServer, setSelectedServer] = useState<Server | undefined>()
const supportedProxyTypes: ProxyType[] = ["http", "tcp", "udp"]
const supportedProxyTypes: ProxyType[] = ['http', 'tcp', 'udp']
const createProxyConfigMutation = useMutation({
mutationKey: ['createProxyConfig', newClientID, newServerID],
mutationFn: () => createProxyConfig({
mutationFn: () =>
createProxyConfig({
clientId: newClientID!,
serverId: newServerID!,
config: ObjToUint8Array({
proxies: proxyConfigs
proxies: proxyConfigs,
} as ClientConfig),
overwrite,
}),
onSuccess: () => {
toast(t('proxy.config.create_success'))
$proxyTableRefetchTrigger.set(Math.random())
onSuccess?.()
},
onError: (e) => {
toast(t('proxy.config.create_failed'), {
description: JSON.stringify(e),
})
$proxyTableRefetchTrigger.set(Math.random())
}
},
})
useEffect(() => {
@@ -116,19 +123,30 @@ export const ProxyConfigMutateForm = ({ overwrite, defaultProxyConfig, defaultOr
<BaseSelector
dataList={supportedProxyTypes.map((type) => ({ value: type, label: type }))}
value={proxyType}
setValue={(value) => { setProxyType(value as ProxyType) }}
setValue={(value) => {
setProxyType(value as ProxyType)
}}
/>
{proxyConfigs && selectedServer && proxyConfigs.length > 0 &&
proxyConfigs[0] && TypedProxyConfigValid(proxyConfigs[0]) &&
<div className='flex flex-row w-full overflow-auto'>
<div className='flex flex-col'>
{proxyConfigs &&
selectedServer &&
proxyConfigs.length > 0 &&
proxyConfigs[0] &&
TypedProxyConfigValid(proxyConfigs[0]) && (
<div className="flex flex-row w-full overflow-auto">
<div className="flex flex-col">
<VisitPreview server={selectedServer} typedProxyConfig={proxyConfigs[0]} />
</div>
</div>
}
)}
<Label>{t('proxy.config.proxy_name')} </Label>
<Input className='text-sm' defaultValue={proxyName} onChange={(e) => setProxyName(e.target.value)} disabled={disableChangeProxyName} />
{proxyName && newClientID && newServerID && <TypedProxyForm
<Input
className="text-sm"
defaultValue={proxyName}
onChange={(e) => setProxyName(e.target.value)}
disabled={disableChangeProxyName}
/>
{proxyName && newClientID && newServerID && (
<TypedProxyForm
serverID={newServerID}
clientID={newClientID}
proxyName={proxyName}
@@ -136,7 +154,8 @@ export const ProxyConfigMutateForm = ({ overwrite, defaultProxyConfig, defaultOr
clientProxyConfigs={proxyConfigs}
setClientProxyConfigs={setProxyConfigs}
enablePreview={false}
/>}
/>
)}
<Button
disabled={!TypedProxyConfigValid(proxyConfigs[0])}
onClick={() => {
@@ -145,7 +164,10 @@ export const ProxyConfigMutateForm = ({ overwrite, defaultProxyConfig, defaultOr
return
}
createProxyConfigMutation.mutate()
}} >{t('proxy.config.submit')}</Button>
}}
>
{t('proxy.config.submit')}
</Button>
</>
)
}

View File

@@ -134,7 +134,7 @@ export function ProxyConfigActions({ serverID, clientID, name, row }: ProxyConfi
return (
<>
<Dialog open={proxyMutateFormOpen} onOpenChange={setProxyMutateFormOpen}>
<DialogContent className="max-h-screen overflow-auto">
<DialogContent className="max-h-[90vh] overflow-auto">
<ProxyConfigMutateForm
disableChangeProxyName
defaultProxyConfig={JSON.parse(row.original.config || '{}') as TypedProxyConfig}

View File

@@ -26,16 +26,6 @@ export type ProxyConfigTableSchema = {
}
export const columns: ColumnDef<ProxyConfigTableSchema>[] = [
{
accessorKey: 'clientID',
header: function Header() {
const { t } = useTranslation()
return t('proxy.item.client_id')
},
cell: ({ row }) => {
return <div className="font-mono text-nowrap">{row.original.originalProxyConfig.originClientId}</div>
},
},
{
accessorKey: 'name',
header: function Header() {
@@ -56,6 +46,16 @@ export const columns: ColumnDef<ProxyConfigTableSchema>[] = [
return <div className="font-mono text-nowrap">{row.original.type}</div>
},
},
{
accessorKey: 'clientID',
header: function Header() {
const { t } = useTranslation()
return t('proxy.item.client_id')
},
cell: ({ row }) => {
return <div className="font-mono text-nowrap">{row.original.originalProxyConfig.originClientId}</div>
},
},
{
accessorKey: 'serverID',
header: function Header() {

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,219 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ClientSelector } from '../base/client-selector'
import { Cpu, Download, Loader2, Trash } from 'lucide-react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { getWorkerStatus, installWorkerd } from '@/api/worker'
import { Client } from '@/lib/pb/common'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface ClientDeploymentProps {
workerId: string
deployedClientIDs: string[]
setDeployedClientIDs: (ids: string[]) => void
clients?: Client[]
}
export function ClientDeployment({
workerId,
deployedClientIDs,
setDeployedClientIDs,
clients = [],
}: ClientDeploymentProps) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedClientId, setSelectedClientId] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
const { data: statusResp } = useQuery({
queryKey: ['workerStatus', workerId],
queryFn: () => getWorkerStatus({ workerId }),
enabled: !!workerId,
refetchInterval: 10000,
})
const installWorkerdMutation = useMutation({
mutationFn: installWorkerd,
onSuccess: () => {
toast.success(t('worker.client_install_workerd.success'))
},
onError: () => {
toast.error(t('worker.client_install_workerd.error'))
},
})
const statusMap = statusResp?.workerStatus || {}
const handleAddClient = () => {
if (selectedClientId && !deployedClientIDs.includes(selectedClientId)) {
setDeployedClientIDs([...deployedClientIDs, selectedClientId])
setSelectedClientId('')
setDialogOpen(false)
}
}
const handleRemoveClient = (clientId: string) => {
setDeployedClientIDs(deployedClientIDs.filter((id) => id !== clientId))
}
function getStatusInfo(status?: string): {
variant: 'outline' | 'default' | 'secondary' | 'destructive'
text: string
} {
if (status === 'running') {
return { variant: 'default', text: t('worker.status_running') }
} else if (status === 'stopped') {
return { variant: 'destructive', text: t('worker.status_stopped') }
} else if (status === 'error') {
return { variant: 'secondary', text: t('worker.status_error') }
} else {
return { variant: 'outline', text: t('worker.status_unknown') }
}
}
return (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Cpu className="h-5 w-5 mr-2 text-muted-foreground" />
{t('worker.deploy.title')}
</CardTitle>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="h-8 text-xs">
{t('worker.deploy.add_client')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('worker.deploy.select_client')}</DialogTitle>
<DialogDescription>{t('worker.deploy.client_description')}</DialogDescription>
</DialogHeader>
<div className="py-4">
<ClientSelector clientID={selectedClientId} setClientID={setSelectedClientId} />
</div>
<DialogFooter>
<Button onClick={handleAddClient} disabled={!selectedClientId}>
{t('common.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{deployedClientIDs.length === 0 ? (
<div className="text-sm text-muted-foreground flex items-center justify-center py-6 border border-dashed rounded-md">
{t('worker.deploy.no_clients')}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{deployedClientIDs.map((clientId) => {
const clientStatus = statusMap[clientId]
const { variant, text } = getStatusInfo(clientStatus)
const client = clients.find((c) => c.id === clientId)
return (
<div
key={clientId}
className="group flex flex-col overflow-hidden rounded-md border hover:border-primary/40 hover:shadow-sm transition-all duration-200"
>
<div className="flex items-center justify-between bg-muted/30 px-3 py-2 border-b">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h3 className="font-semibold text-sm truncate max-w-[200px] md:max-w-[300px]">
{client?.originClientId || clientId}
</h3>
</TooltipTrigger>
<TooltipContent>{client?.originClientId || clientId}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Badge variant={variant} className="font-normal whitespace-nowrap">
{text}
</Badge>
</div>
<div className="px-3 py-2 flex-grow">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<span className="text-xs font-medium text-muted-foreground mr-1">ID:</span>
<span className="font-mono text-xs truncate max-w-[200px]">{clientId}</span>
</div>
</TooltipTrigger>
<TooltipContent>{clientId}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="bg-muted/10 px-3 py-1.5 flex items-center justify-end border-t">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Download className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('worker.client_install_workerd.title')}</DialogTitle>
<DialogDescription>{t('worker.client_install_workerd.description')}</DialogDescription>
</DialogHeader>
<Label>{t('worker.client_install_workerd.download_url')}</Label>
<Input
placeholder={t('worker.client_install_workerd.placeholder')}
defaultValue={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
/>
<DialogFooter className="pt-4">
<Button
onClick={() => {
installWorkerdMutation.mutate({ clientId })
}}
disabled={installWorkerdMutation.isPending}
>
{installWorkerdMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('worker.client_install_workerd.button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveClient(clientId)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
)
})}
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
// 动态加载编辑器组件
const MonacoEditor = dynamic(() => import('@/components/base/monaco').then((m) => m.WorkerEditor), {
ssr: false,
})
interface WorkerCodeEditorProps {
code: string
onChange: (code: string) => void
}
export function WorkerCodeEditor({ code, onChange }: WorkerCodeEditorProps) {
return (
<div className="h-full w-full">
<MonacoEditor code={code} onChange={onChange} />
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSearchParams } from 'next/navigation'
import { useQuery, useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { getWorker, updateWorker } from '@/api/worker'
import { UpdateWorkerRequest } from '@/lib/pb/api_client'
import { Worker } from '@/lib/pb/common'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { WorkerInfoCard } from './info_card'
import { WorkerIngress } from './ingress_section'
import { ClientDeployment } from './client_deploy'
import { WorkerCodeEditor } from './code_editor'
import { WorkerTemplateEditor } from './template_editor'
export default function WorkerEdit() {
const router = useRouter()
const params = useSearchParams()
const workerId = params.get('workerId')!
const { t } = useTranslation()
// 本地状态
const [worker, setWorker] = useState<Worker>({} as Worker)
const [code, setCode] = useState('')
const [template, setTemplate] = useState('')
const [deployedClientIDs, setDeployedClientIDs] = useState<string[]>([])
// 获取 Worker
const { data: resp, refetch: refetchWorker } = useQuery({
queryKey: ['getWorker', workerId],
queryFn: () => getWorker({ workerId }),
enabled: !!workerId,
})
useEffect(() => {
if (resp?.worker) {
setWorker(resp.worker)
setCode(resp.worker.code ?? '')
setTemplate(resp.worker.configTemplate ?? '')
// @ts-ignore
setDeployedClientIDs(resp.clients.map((client) => client.id).filter((id) => id !== undefined) || [])
}
}, [resp])
// 更新 Worker
const updateMut = useMutation({
mutationFn: () => {
const req: UpdateWorkerRequest = {
clientIds: deployedClientIDs,
worker: {
...worker,
code,
configTemplate: template,
},
}
return updateWorker(req)
},
onSuccess: () => {
toast.success(t('worker.edit.save_success'))
refetchWorker()
},
onError: (e) => toast.error(`${t('worker.edit.save_error')}: ${e.message}`),
})
return (
<div className="container p-4 mx-auto space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-xl md:text-2xl font-semibold flex flex-row gap-1 sm:items-center">
<span>{t('worker.edit.title')}</span>
<span className="font-mono text-gray-500">{worker?.name}</span>
</h1>
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={() => router.back()} className="flex-1 sm:flex-none">
{t('common.back')}
</Button>
<Button onClick={() => updateMut.mutate()} disabled={updateMut.isPending} className="flex-1 sm:flex-none">
{updateMut.isPending ? t('common.saving') : t('common.save')}
</Button>
</div>
</div>
<div className="space-y-4">
<Tabs defaultValue="info" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">
{t('worker.edit.info_tab')}
</TabsTrigger>
<TabsTrigger value="code" className="flex-1">
{t('worker.edit.code_tab')}
</TabsTrigger>
<TabsTrigger value="template" className="flex-1">
{t('worker.edit.template_tab')}
</TabsTrigger>
</TabsList>
<TabsContent value="info" className="overflow-hidden space-y-4 mt-4">
<WorkerInfoCard worker={worker} onChange={setWorker} clients={resp?.clients} />
{/* <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> */}
<ClientDeployment
workerId={workerId}
deployedClientIDs={deployedClientIDs}
setDeployedClientIDs={setDeployedClientIDs}
clients={resp?.clients}
/>
<WorkerIngress workerId={workerId} refetchWorker={refetchWorker} clients={resp?.clients} />
{/* </div> */}
</TabsContent>
<TabsContent value="code" className="h-[calc(100vh-240px)] border rounded-md overflow-hidden mt-4">
<WorkerCodeEditor code={code} onChange={setCode} />
</TabsContent>
<TabsContent value="template" className="h-[calc(100vh-240px)] border rounded-md overflow-hidden mt-4">
<WorkerTemplateEditor content={template} onChange={setTemplate} />
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import React from 'react'
import { Client, Worker } from '@/lib/pb/common'
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
import { WorkerStatus } from './worker_status'
import { useQuery } from '@tanstack/react-query'
import { getWorkerIngress } from '@/api/worker'
import { InfoIcon } from 'lucide-react'
// import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface WorkerInfoCardProps {
worker: Worker
onChange: (worker: Worker) => void
clients?: Client[]
}
export function WorkerInfoCard({ worker, onChange, clients = [] }: WorkerInfoCardProps) {
const { t } = useTranslation()
// 获取 Worker Ingress 用于状态统计
const { data: ingressResp } = useQuery({
queryKey: ['getWorkerIngress', worker.workerId],
queryFn: () => getWorkerIngress({ workerId: worker.workerId || '' }),
enabled: !!worker.workerId,
})
return (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<InfoIcon className="h-5 w-5 mr-2 text-muted-foreground" />
{t('worker.info.basic_info')}
</CardTitle>
<WorkerStatus workerId={worker.workerId || ''} clients={clients} />
</div>
<CardDescription className="text-xs">{t('worker.info.info_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium">{t('worker.info.worker_id')}</Label>
<div className="flex">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Input value={worker.workerId || ''} readOnly className="bg-muted font-mono text-sm h-9" />
</TooltipTrigger>
<TooltipContent>{worker.workerId || ''}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">{t('worker.info.worker_name')}</Label>
<div className="flex">
<Input
value={worker.name || ''}
onChange={(e) => onChange({ ...worker, name: e.target.value })}
className="h-9 text-sm"
placeholder={t('worker.info.name_placeholder')}
/>
</div>
</div>
</div>
{/* 暂不实现 */}
{/* <div className="space-y-2">
<Label className="text-xs font-medium">{t('worker.info.description')}</Label>
<Textarea
value={worker.description || ''}
onChange={(e) => onChange({ ...worker, description: e.target.value })}
placeholder={t('worker.info.description_placeholder')}
className="resize-none h-20 text-sm"
/>
</div> */}
<div className="flex items-center justify-between rounded-md bg-muted/50 p-3 border">
<div className="space-y-1">
<h4 className="text-sm font-medium">{t('worker.info.resources')}</h4>
<div className="flex space-x-4 text-xs text-muted-foreground">
<div>
{t('worker.info.clients')}: <span className="font-medium">{clients.length}</span>
</div>
<div>
{t('worker.info.ingresses')}:{' '}
<span className="font-medium">{ingressResp?.proxyConfigs?.length || 0}</span>
</div>
</div>
</div>
{/* 暂不实现 */}
{/* <div className="text-xs bg-primary/10 text-primary px-2 py-1 rounded-md font-medium">
{worker.version || t('worker.info.unknown_version')}
</div> */}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,363 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { createWorkerIngress, getWorkerIngress } from '@/api/worker'
import { Client, ProxyConfig } from '@/lib/pb/common'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { ProxyConfigMutateForm } from '../proxy/mutate_proxy_config'
import { Loader2, Settings, Trash, Network } from 'lucide-react'
import { TypedProxyConfig } from '@/types/proxy'
import { ClientSelector } from '../base/client-selector'
import { ServerSelector } from '../base/server-selector'
import { Label } from '@/components/ui/label'
import { deleteProxyConfig, getProxyConfig } from '@/api/proxy'
import { useStore } from '@nanostores/react'
import { $proxyTableRefetchTrigger } from '@/store/refetch-trigger'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
interface WorkerIngressProps {
workerId: string
refetchWorker: () => void
clients?: Client[]
}
// 创建 Worker Ingress 表单组件
const CreateWorkerIngressForm = ({
workerId,
onSuccess,
clients,
}: {
workerId: string
onSuccess: () => void
clients?: Client[]
}) => {
const { t } = useTranslation()
const [clientId, setClientId] = useState<string>('')
const [serverId, setServerId] = useState<string>('')
const createWorkerIngressMutation = useMutation({
mutationFn: createWorkerIngress,
onSuccess: () => {
toast.success(t('worker.ingress.create_success'))
onSuccess()
},
onError: (error) => {
toast.error(t('worker.ingress.create_failed'), {
description: error instanceof Error ? error.message : String(error),
})
},
})
const handleSubmit = () => {
if (!clientId) {
toast.error(t('worker.ingress.client_required'))
return
}
if (!serverId) {
toast.error(t('worker.ingress.server_required'))
return
}
createWorkerIngressMutation.mutate({
clientId,
serverId,
workerId,
})
}
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-select">{t('worker.ingress.select_client')}</Label>
<ClientSelector setClientID={setClientId} clientID={clientId} clients={clients} />
</div>
<div className="space-y-2">
<Label htmlFor="server-select">{t('worker.ingress.select_server')}</Label>
<ServerSelector setServerID={setServerId} serverID={serverId} />
</div>
<DialogFooter>
<Button disabled={!clientId || !serverId || createWorkerIngressMutation.isPending} onClick={handleSubmit}>
{createWorkerIngressMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('worker.ingress.creating')}
</>
) : (
t('worker.ingress.create_submit')
)}
</Button>
</DialogFooter>
</div>
)
}
function ProxyStatusBadge({
clientId,
serverId,
proxyName,
}: {
clientId?: string
serverId?: string
proxyName?: string
}) {
const { t } = useTranslation()
const refetchTrigger = useStore($proxyTableRefetchTrigger)
const { data } = useQuery({
queryKey: ['getProxyConfig', clientId, serverId, proxyName, refetchTrigger],
queryFn: () => {
return getProxyConfig({
clientId,
serverId,
name: proxyName,
})
},
enabled: !!clientId && !!serverId && !!proxyName,
refetchInterval: 10000,
})
function getStatusInfo(status: string): {
color: string
text: string
variant: 'outline' | 'default' | 'secondary' | 'destructive'
} {
switch (status) {
case 'new':
return { color: 'bg-blue-100 border-blue-400', text: t('status.new'), variant: 'secondary' }
case 'wait start':
return { color: 'bg-yellow-100 border-yellow-400', text: t('status.wait_start'), variant: 'secondary' }
case 'start error':
return { color: 'bg-red-100 border-red-400', text: t('status.start_error'), variant: 'destructive' }
case 'running':
return { color: 'bg-green-100 border-green-400', text: t('status.running'), variant: 'default' }
case 'check failed':
return { color: 'bg-orange-100 border-orange-400', text: t('status.check_failed'), variant: 'secondary' }
case 'error':
return { color: 'bg-red-100 border-red-400', text: t('status.error'), variant: 'destructive' }
default:
return { color: 'bg-gray-100 border-gray-400', text: t('status.unknown'), variant: 'outline' }
}
}
const status = data?.workingStatus?.status || 'unknown'
const { text, variant } = getStatusInfo(status)
return (
<Badge variant={variant} className={'font-normal whitespace-nowrap'}>
{text}
</Badge>
)
}
export function WorkerIngress({ workerId, refetchWorker, clients }: WorkerIngressProps) {
const { t } = useTranslation()
// 获取 Worker Ingress
const { data: ingresses, refetch: refetchIngresses } = useQuery({
queryKey: ['getWorkerIngress', workerId],
queryFn: () => getWorkerIngress({ workerId }),
enabled: !!workerId,
})
const handleIngressCreated = () => {
refetchIngresses()
refetchWorker()
}
return (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Network className="h-5 w-5 mr-2 text-muted-foreground" />
{t('worker.ingress.title')}
</CardTitle>
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="h-8 text-xs">
{t('worker.ingress.create')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('worker.ingress.create_title')}</DialogTitle>
<DialogDescription>{t('worker.ingress.create_description')}</DialogDescription>
</DialogHeader>
<CreateWorkerIngressForm workerId={workerId} onSuccess={handleIngressCreated} clients={clients} />
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{!ingresses || !ingresses.proxyConfigs || ingresses.proxyConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground flex items-center justify-center py-6 border border-dashed rounded-md">
{t('worker.ingress.no_ingress')}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 rounded-md">
{ingresses.proxyConfigs.map((ingress: ProxyConfig) => (
<div
key={ingress.id}
className="group overflow-hidden rounded-md border hover:border-primary/40 hover:shadow-sm transition-all duration-200 flex flex-col"
>
<div className="flex items-center justify-between bg-muted/30 px-3 py-2 border-b">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h3 className="font-semibold text-sm truncate max-w-[200px] md:max-w-[300px]">
{ingress.name}
</h3>
</TooltipTrigger>
<TooltipContent>{ingress.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
<ProxyStatusBadge
clientId={ingress.clientId}
serverId={ingress.serverId}
proxyName={ingress.name}
/>
</div>
<div className="px-3 py-2 flex-grow">
<div className="flex flex-col space-y-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<span className="text-xs font-medium text-muted-foreground mr-1">Server:</span>
<span className="font-mono text-xs truncate max-w-[150px] md:max-w-[200px]">
{ingress.serverId}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{ingress.serverId}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<span className="text-xs font-medium text-muted-foreground mr-1">Client:</span>
<span className="font-mono text-xs truncate max-w-[150px] md:max-w-[200px]">
{ingress.originClientId}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{ingress.originClientId}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="bg-muted/10 px-3 py-1.5 flex items-center justify-end gap-1 border-t">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('worker.ingress.edit')}</DialogTitle>
</DialogHeader>
<ProxyConfigMutateForm
disableChangeProxyName
defaultProxyConfig={JSON.parse(ingress.config || '{}') as TypedProxyConfig}
overwrite={true}
defaultOriginalProxyConfig={ingress}
onSuccess={handleIngressCreated}
/>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
>
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('worker.ingress.delete.title')}</DialogTitle>
<DialogDescription>{t('worker.ingress.delete.description')}</DialogDescription>
</DialogHeader>
<IngressDeleteForm
clientId={ingress.clientId}
serverId={ingress.serverId}
proxyName={ingress.name}
onSuccess={handleIngressCreated}
/>
</DialogContent>
</Dialog>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
)
}
export const IngressDeleteForm = ({
clientId,
serverId,
proxyName,
onSuccess,
}: {
clientId?: string
serverId?: string
proxyName?: string
onSuccess?: () => void
}) => {
const { t } = useTranslation()
// 删除 Ingress 对应的 ProxyConfig
const deleteProxyConfigMutation = useMutation({
mutationFn: deleteProxyConfig,
onSuccess: () => {
onSuccess?.()
toast(t('worker.ingress.delete_success'), {
description: t('worker.ingress.delete_description', { proxyName }),
})
},
})
return (
<DialogFooter className="pt-4">
<Button
variant={'destructive'}
onClick={() =>
deleteProxyConfigMutation.mutate({
clientId,
serverId,
name: proxyName,
})
}
disabled={deleteProxyConfigMutation.isPending}
>
{deleteProxyConfigMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('worker.ingress.delete.button')}
</Button>
</DialogFooter>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import React, { useLayoutEffect } from 'react'
import Editor, { loader } from '@monaco-editor/react'
loader.config({
paths: {
vs: 'https://fastly.jsdelivr.net/npm/monaco-editor@0.36.1/min/vs',
},
})
export interface TemplateEditorProps {
content: string
onChange: (value: string) => void
}
export function TemplateEditor({ content, onChange }: TemplateEditorProps) {
useLayoutEffect(() => {}, [])
return (
<div className="h-full">
<Editor height="100%" defaultLanguage="capnp" value={content} onChange={(v) => onChange(v ?? '')} />
</div>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
// 动态加载模板编辑器组件
const TemplateEditorComponent = dynamic(
() => import('@/components/worker/template_edit').then((m) => m.TemplateEditor),
{ ssr: false },
)
interface WorkerTemplateEditorProps {
content: string
onChange: (content: string) => void
}
export function WorkerTemplateEditor({ content, onChange }: WorkerTemplateEditorProps) {
return (
<div className="h-full w-full">
<TemplateEditorComponent content={content} onChange={onChange} />
</div>
)
}

View File

@@ -0,0 +1,116 @@
import React from 'react'
import { useMutation } from '@tanstack/react-query'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createWorker } from '@/api/worker'
import { CreateWorkerRequest } from '@/lib/pb/api_client'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Form, FormField, FormItem, FormLabel, FormControl } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ClientSelector } from '../base/client-selector'
const CreateWorkerSchema = z.object({
clientId: z.string().min(1, 'worker.createClientRequired'),
name: z.string().min(1, 'worker.createNameRequired'),
})
type CreateWorkerValues = z.infer<typeof CreateWorkerSchema>
export interface CreateWorkerDialogProps {
refetchTrigger: React.Dispatch<React.SetStateAction<string>>
}
export const CreateWorkerDialog: React.FC<CreateWorkerDialogProps> = ({ refetchTrigger }) => {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const form = useForm<CreateWorkerValues>({
resolver: zodResolver(CreateWorkerSchema),
defaultValues: {
clientId: '',
name: '',
},
})
const { mutate, isPending } = useMutation({
mutationFn: (values: CreateWorkerValues) => {
const req: CreateWorkerRequest = {
clientId: values.clientId,
worker: { name: values.name },
}
return createWorker(req)
},
onSuccess: () => {
toast.success(t('worker.create.success'))
form.reset()
setOpen(false)
refetchTrigger(new Date().toISOString())
},
onError: (err: any) => {
toast(err?.message || t('worker.create'))
},
})
const onSubmit = (values: CreateWorkerValues) => {
mutate(values)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('worker.create.button')}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('worker.create.title')}</DialogTitle>
<DialogDescription>{t('worker.create.description')}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>{t('worker.create.clientIdLabel')}</FormLabel>
<FormControl>
<ClientSelector clientID={field.value} setClientID={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('worker.create.nameLabel')}</FormLabel>
<FormControl>
<Input placeholder={t('worker.create.namePlaceholder')} {...field} />
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? t('worker.create.creating') : t('worker.create.submit')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,183 @@
import { MoreHorizontal, ArrowUpRight } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { removeWorker, getWorker, getWorkerIngress, getWorkerStatus } from '@/api/worker'
import { $workerTableRefetchTrigger } from '@/store/refetch-trigger'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/router'
import { Worker } from '@/lib/pb/common'
import { toast } from 'sonner'
import { useMutation, useQuery } from '@tanstack/react-query'
import { WorkerStatus } from './worker_status'
import { ColumnDef, Row } from '@tanstack/react-table'
export type WorkerTableSchema = {
workerId: string
name: string
userId: number
tenantId: number
socketAddress: string
origin: Worker
}
export const columns: ColumnDef<WorkerTableSchema>[] = [
{
accessorKey: 'name',
header: ({ column }: { column: ColumnDef<WorkerTableSchema> }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useTranslation()
return t('worker.columns.name')
},
cell: ({ row }: { row: Row<WorkerTableSchema> }) => {
const worker = row.original
// eslint-disable-next-line react-hooks/rules-of-hooks
const router = useRouter()
return (
<div className="flex items-center">
<span className="mr-2 font-medium">{worker.name || worker.workerId}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full"
onClick={() =>
router.push({
pathname: '/worker-edit',
query: { workerId: worker.workerId },
})
}
>
<ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</div>
)
},
},
{
id: 'status',
header: ({ column }: { column: ColumnDef<WorkerTableSchema> }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useTranslation()
return t('worker.columns.status')
},
cell: ({ row }: { row: Row<WorkerTableSchema> }) => {
const workerId = row.getValue('workerId') as string
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data: workerData } = useQuery({
queryKey: ['getWorker', workerId],
queryFn: () => getWorker({ workerId }),
enabled: !!workerId,
})
return (
<div className="flex justify-start">
<WorkerStatus workerId={workerId} clients={workerData?.clients || []} />
</div>
)
},
},
{
accessorKey: 'workerId',
header: ({ column }: { column: ColumnDef<WorkerTableSchema> }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useTranslation()
return t('worker.columns.id')
},
cell: ({ row }: { row: Row<WorkerTableSchema> }) => {
return <div className="font-mono text-sm text-nowarp whitespace-nowrap">{row.getValue('workerId')}</div>
},
},
{
id: 'actions',
cell: ({ row }: { row: Row<WorkerTableSchema> }) => {
const worker = row.original
return <WorkerActions worker={worker} />
},
},
]
interface WorkerActionsProps {
worker: WorkerTableSchema
}
export const WorkerActions: React.FC<WorkerActionsProps> = ({ worker }) => {
const { t } = useTranslation()
const router = useRouter()
const del = useMutation({
mutationFn: () => removeWorker({ workerId: worker.workerId }),
onSuccess: () => {
toast.success(t('worker.actions_menu.delete') + t('common.success'))
$workerTableRefetchTrigger.set(Math.random())
},
onError: (err: any) => {
toast(t('common.failed'), { description: err.message })
$workerTableRefetchTrigger.set(Math.random())
},
})
return (
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('worker.actions_menu.title')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
router.push({
pathname: '/worker-edit',
query: { workerId: worker.workerId },
})
}
>
{t('worker.actions_menu.edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DialogTrigger asChild>
<DropdownMenuItem className="text-destructive">{t('worker.actions_menu.delete')}</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('worker.delete.title')}</DialogTitle>
<DialogDescription>{t('worker.delete.description', { name: worker.name })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" className="mr-2">
{t('common.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="destructive" onClick={() => del.mutate()}>
{t('worker.delete.confirm')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,77 @@
import React from 'react'
import { useRouter } from 'next/router'
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
SortingState,
PaginationState,
ColumnFiltersState,
} from '@tanstack/react-table'
import { useStore } from '@nanostores/react'
import { listWorkers } from '@/api/worker'
import { Worker as PbWorker } from '@/lib/pb/common'
import { DataTable } from '../base/data_table'
import { WorkerTableSchema, columns as workerColumnsDef } from './worker_item'
import { $workerTableRefetchTrigger } from '@/store/refetch-trigger'
export interface WorkerListProps {
initialWorkers: PbWorker[]
initialTotal: number
triggerRefetch?: string
keyword?: string
}
export const WorkerList: React.FC<WorkerListProps> = ({ initialWorkers, initialTotal, triggerRefetch, keyword }) => {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [{ pageIndex, pageSize }, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const globalTrigger = useStore($workerTableRefetchTrigger)
const fetchOptions = { pageIndex, pageSize, triggerRefetch, globalTrigger, keyword }
const { data, isFetching } = useQuery({
queryKey: ['listWorkers', fetchOptions],
queryFn: () =>
listWorkers({
page: pageIndex + 1,
pageSize,
keyword,
}),
placeholderData: keepPreviousData,
})
const dataRows: WorkerTableSchema[] =
data?.workers.map((w) => ({
workerId: w.workerId ?? '',
name: w.name ?? '',
userId: w.userId ?? 0,
tenantId: w.tenantId ?? 0,
socketAddress: w.socket?.address ?? '',
origin: w,
})) ?? []
const table = useReactTable({
data: dataRows,
columns: workerColumnsDef,
state: { sorting, pagination: { pageIndex, pageSize }, columnFilters },
manualPagination: true,
pageCount: Math.ceil((data?.total ?? 0) / pageSize),
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return <DataTable table={table} columns={workerColumnsDef} />
}

View File

@@ -0,0 +1,233 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { getWorkerStatus, getWorkerIngress } from '@/api/worker'
import { getProxyConfig } from '@/api/proxy'
import { useStore } from '@nanostores/react'
import { $proxyTableRefetchTrigger } from '@/store/refetch-trigger'
import { Client, ProxyConfig } from '@/lib/pb/common'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { Cpu, Network } from 'lucide-react'
interface WorkerStatusProps {
workerId: string
clients?: Client[]
compact?: boolean
}
export function WorkerStatus({ workerId, clients = [], compact = false }: WorkerStatusProps) {
const { t } = useTranslation()
const refetchTrigger = useStore($proxyTableRefetchTrigger)
// 获取 Worker 状态
const { data: statusResp } = useQuery({
queryKey: ['workerStatus', workerId],
queryFn: () => getWorkerStatus({ workerId }),
enabled: !!workerId,
refetchInterval: 10000,
})
// 获取 Worker Ingress
const { data: ingressResp } = useQuery({
queryKey: ['getWorkerIngress', workerId],
queryFn: () => getWorkerIngress({ workerId }),
enabled: !!workerId,
})
// 统计部署状态
const clientStatuses = statusResp?.workerStatus || {}
const deployedClients = clients || []
const totalClients = deployedClients.length
const runningClients = Object.entries(clientStatuses).filter(([_, status]) => status === 'running').length
const errorClients = Object.entries(clientStatuses).filter(([_, status]) => status === 'error').length
const stoppedClients = Object.entries(clientStatuses).filter(([_, status]) => status === 'stopped').length
// 统计入口状态
const ingresses = ingressResp?.proxyConfigs || []
const totalIngresses = ingresses.length
// 查询所有入口的状态
const ingressStatuses = useQuery({
queryKey: ['getIngressStatuses', workerId, ingresses.map((i: ProxyConfig) => i.id).join(','), refetchTrigger],
queryFn: async () => {
const statuses: Record<string, string> = {}
await Promise.all(
ingresses.map(async (ingress: ProxyConfig) => {
try {
const proxyStatus = await getProxyConfig({
clientId: ingress.clientId,
serverId: ingress.serverId,
name: ingress.name,
})
statuses[ingress.id || ''] = proxyStatus?.workingStatus?.status || 'unknown'
} catch (e) {
statuses[ingress.id || ''] = 'error'
}
}),
)
return statuses
},
enabled: ingresses.length > 0,
refetchInterval: 10000,
})
const runningIngresses = Object.values(ingressStatuses.data || {}).filter((status) => status === 'running').length
const errorIngresses = Object.values(ingressStatuses.data || {}).filter((status) =>
['error', 'start error', 'check failed'].includes(status),
).length
// 计算总体状态
const getOverallStatus = () => {
if (totalClients === 0 && totalIngresses === 0) {
return { variant: 'outline' as const, text: t('worker.status.no_resources'), color: 'bg-gray-100 text-gray-700' }
}
// 资源完全不可用
if ((totalClients > 0 && runningClients === 0) || (totalIngresses > 0 && runningIngresses === 0)) {
return { variant: 'destructive' as const, text: t('worker.status.unusable'), color: 'bg-red-500 text-white' }
}
// 资源不健康但部分可用
if (errorClients > 0 || errorIngresses > 0) {
return { variant: 'warning' as const, text: t('worker.status.unhealthy'), color: 'bg-amber-500 text-white' }
}
// 所有资源健康
if (runningClients === totalClients && runningIngresses === totalIngresses) {
return { variant: 'default' as const, text: t('worker.status.healthy'), color: 'bg-green-500 text-white' }
}
// 部分资源降级但无错误
return { variant: 'secondary' as const, text: t('worker.status.degraded'), color: 'bg-orange-500 text-white' }
}
const { variant, text, color } = getOverallStatus()
// 生成客户端资源状态指示器
const renderClientIndicators = () => {
if (totalClients === 0) return null
return (
<div className="flex items-center group relative">
<div className="flex items-center space-x-1 rounded-md bg-muted/30 px-1 py-0.5 border border-muted">
<div className="flex space-x-0.5">
{Array.from({ length: Math.min(totalClients, 3) }).map((_, i) => (
<div
key={`client-${i}`}
className={`h-2.5 w-2.5 rounded-sm ${
i < runningClients ? 'bg-green-500' : i < runningClients + errorClients ? 'bg-red-500' : 'bg-gray-300'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
<Cpu className="w-3 h-3 min-w-3 min-h-3 max-w-3 max-h-3" />
</span>
{totalClients > 3 && <span className="text-xs text-muted-foreground">+{totalClients - 3}</span>}
</div>
</div>
)
}
// 生成入口资源状态指示器
const renderIngressIndicators = () => {
if (totalIngresses === 0) return null
return (
<div className="flex items-center group relative">
<div className="flex items-center space-x-1 rounded-md bg-muted/30 px-1 py-0.5 border border-muted">
<div className="flex space-x-0.5">
{Array.from({ length: Math.min(totalIngresses, 3) }).map((_, i) => (
<div
key={`ingress-${i}`}
className={`h-2.5 w-2.5 rounded-sm ${
i < runningIngresses
? 'bg-green-500'
: i < runningIngresses + errorIngresses
? 'bg-red-500'
: 'bg-gray-300'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
<Network className="w-3 h-3 min-w-3 min-h-3 max-w-3 max-h-3" />
</span>
{totalIngresses > 3 && <span className="text-xs text-muted-foreground">+{totalIngresses - 3}</span>}
</div>
</div>
)
}
if (compact) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex items-center space-x-1">
<div
className={`h-2 w-2 rounded-sm ${
variant === 'default'
? 'bg-green-500'
: variant === 'destructive'
? 'bg-red-500'
: variant === 'warning'
? 'bg-amber-500'
: variant === 'secondary'
? 'bg-blue-500'
: 'bg-gray-300'
}`}
/>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<p className="text-sm font-medium">{text}</p>
<div className="text-xs">
<div className="flex items-center space-x-1 font-mono">
{t('worker.status.clients')}: {runningClients}/{totalClients} {t('worker.status.running')}
</div>
<div className="flex items-center space-x-1 font-mono">
{t('worker.status.ingresses')}: {runningIngresses}/{totalIngresses} {t('worker.status.running')}
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
{renderIngressIndicators()}
{renderClientIndicators()}
</div>
<Badge className={`px-2 py-0.5 ${color} whitespace-nowrap`}>{text}</Badge>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-2">
<p className="text-sm font-medium">{text}</p>
<div className="space-y-1 text-xs">
<div className="flex items-center space-x-1 font-mono">
{t('worker.status.clients')}: {runningClients}/{totalClients} {t('worker.status.running')}
</div>
<div className="flex items-center space-x-1 font-mono">
{t('worker.status.ingresses')}: {runningIngresses}/{totalIngresses} {t('worker.status.running')}
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -7,6 +7,7 @@ import {
ChartNetworkIcon,
Scroll,
Cable,
SquareFunction,
} from "lucide-react"
import { TbBuildingTunnel } from "react-icons/tb"
@@ -61,4 +62,9 @@ export const getNavItems = (t: any) => [
url: "/console",
icon: SquareTerminal,
},
{
title: t('nav.workers'),
url: "/workers",
icon: SquareFunction,
},
]

View File

@@ -177,6 +177,13 @@
"register": "Register",
"userInfo": "User Information",
"logout": "Logout",
"add": "Add",
"back": "Back",
"saving": "Saving...",
"save": "Save",
"success": "Success",
"failed": "Failed",
"cancel": "Cancel",
"clientType": "Client Type",
"disconnect": "Disconnect",
"connect": "Connect"
@@ -299,9 +306,9 @@
"title": "Tunnel Operation"
},
"item": {
"client_id": "Client ID",
"proxy_name": "Tunnel Name",
"proxy_type": "Tunnel Type",
"client_id": "Client ID",
"server_id": "Server ID",
"status": "Status",
"visit_preview": "Visit Preview"
@@ -470,6 +477,113 @@
"cancel": "Cancel",
"confirm": "Confirm"
},
"worker": {
"client_install_workerd": {
"success": "Workerd installed successfully",
"error": "Failed to install Workerd",
"title": "Install Workerd",
"description": "Install Workerd binary from url to Client",
"download_url": "Workerd Binary Download URL",
"placeholder": "Enter the download URL",
"button": "Install"
},
"status_running": "Running",
"status_stopped": "Stopped",
"status_error": "Error",
"status_unknown": "Unknown",
"deploy": {
"title": "Deploy",
"add_client": "Add Deployment",
"select_client": "Select Client",
"client_description": "Select a client to deploy",
"no_clients": "No clients available"
},
"edit": {
"save_success": "Changes saved successfully",
"save_error": "Failed to save changes",
"title": "Edit Worker",
"info_tab": "Worker Info",
"code_tab": "Code",
"template_tab": "Template"
},
"info": {
"basic_info": "Basic Information",
"info_description": "You can edit the basic information",
"worker_id": "Worker ID",
"worker_name": "Worker Name",
"name_placeholder": "Enter worker name",
"resources": "Resources",
"clients": "Deployments",
"ingresses": "Ingresses"
},
"ingress": {
"create_success": "Ingress created successfully",
"create_failed": "Failed to create ingress",
"client_required": "Client is required",
"server_required": "Server is required",
"select_client": "Select Client",
"select_server": "Select Server",
"creating": "Creating Ingress...",
"create_submit": "Create Ingress",
"title": "Ingress",
"create": "Create Ingress",
"create_title": "Create Ingress",
"create_description": "Create a new Ingress",
"no_ingress": "No Ingresses",
"edit": "Edit Ingress",
"delete": {
"title": "Delete Ingress",
"description": "Are you sure you want to delete this ingress?",
"button": "Delete"
},
"delete_success": "Ingress deleted successfully",
"delete_description": "Ingress deleted successfully"
},
"create": {
"button": "Create Worker",
"title": "Create Worker",
"description": "Create a new Worker",
"clientIdLabel": "Client ID",
"nameLabel": "Worker Name",
"namePlaceholder": "Enter Worker Name",
"creating": "Creating Worker...",
"submit": "Create Worker"
},
"columns": {
"name": "Name",
"status": "Status",
"id": "ID"
},
"actions_menu": {
"delete": "Delete Worker",
"title": "Worker Actions",
"edit": "Edit Worker"
},
"delete": {
"title": "Delete Worker",
"description": "Are you sure you want to delete this worker?",
"confirm": "Delete Worker"
},
"status": {
"no_resources": "No Resources",
"unusable": "Unusable",
"unhealthy": "Unhealthy",
"healthy": "Healthy",
"degraded": "Degraded",
"clients": "Deployments",
"running": "Running",
"ingresses": "Ingresses"
}
},
"status": {
"new": "New",
"wait_start": "Waiting to Start",
"start_error": "Start Error",
"running": "Running",
"check_failed": "Check Failed",
"error": "Error",
"unknown": "Unknown"
},
"nav": {
"clients": "Clients",
"servers": "Servers",
@@ -478,7 +592,8 @@
"editServer": "Edit Server",
"trafficStats": "Traffic Stats",
"realTimeLog": "Real-time Log",
"console": "Console"
"console": "Console",
"workers": "Functions"
},
"frpc_form": {
"add": "Add Client",

View File

@@ -23,11 +23,8 @@
"streamlog": "Stream Log",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Information",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"newWindow": "New Window"
@@ -121,5 +118,9 @@
"toggle": "Toggle Language",
"zh": "Chinese",
"en": "English"
},
"worker": {
"name": "Worker Name",
"id": "Worker ID"
}
}

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