Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
2026-02-08 00:57:08 +08:00
125 changed files with 20623 additions and 7709 deletions

424
server/api/controller.go Normal file
View File

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

136
server/api/types.go Normal file
View File

@@ -0,0 +1,136 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type ServerInfoResp struct {
Version string `json:"version"`
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr,omitempty"`
TLSForce bool `json:"tlsForce,omitempty"`
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type ClientInfoResp struct {
Key string `json:"key"`
User string `json:"user"`
ClientID string `json:"clientID"`
RunID string `json:"runID"`
Hostname string `json:"hostname"`
ClientIP string `json:"clientIP,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
LastConnectedAt int64 `json:"lastConnectedAt"`
DisconnectedAt int64 `json:"disconnectedAt,omitempty"`
Online bool `json:"online"`
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
type TCPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type TCPMuxOutConf struct {
BaseOutConf
v1.DomainConfig
Multiplexer string `json:"multiplexer"`
RouteByHTTPUser string `json:"routeByHTTPUser"`
}
type UDPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type HTTPOutConf struct {
BaseOutConf
v1.DomainConfig
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"hostHeaderRewrite"`
}
type HTTPSOutConf struct {
BaseOutConf
v1.DomainConfig
}
type STCPOutConf struct {
BaseOutConf
}
type XTCPOutConf struct {
BaseOutConf
}
// Get proxy info.
type ProxyStatsInfo struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
type GetProxyInfoResp struct {
Proxies []*ProxyStatsInfo `json:"proxies"`
}
// Get proxy info by name.
type GetProxyStatsResp struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
// /api/traffic/:name
type GetProxyTrafficResp struct {
Name string `json:"name"`
TrafficIn []int64 `json:"trafficIn"`
TrafficOut []int64 `json:"trafficOut"`
}

View File

@@ -40,6 +40,7 @@ import (
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/metrics"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type ControlManager struct {
@@ -192,6 +193,8 @@ type Control struct {
// Server configuration information
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
@@ -403,6 +406,7 @@ func (ctl *Control) worker() {
}
metrics.Server.CloseClient()
ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}
@@ -446,7 +450,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
} else {
resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
clientID := ctl.loginMsg.ClientID
if clientID == "" {
clientID = ctl.loginMsg.RunID
}
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
}
_ = ctl.msgDispatcher.Send(resp)
}

View File

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

View File

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

179
server/registry/registry.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package registry
import (
"fmt"
"sync"
"time"
)
// ClientInfo captures metadata about a connected frpc instance.
type ClientInfo struct {
Key string
User string
RawClientID string
RunID string
Hostname string
IP string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
Online bool
}
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty).
// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.
type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
}
func NewClientRegistry() *ClientRegistry {
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
runIndex: make(map[string]string),
}
}
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
if runID == "" {
return "", false
}
effectiveID := rawClientID
if effectiveID == "" {
effectiveID = runID
}
key = cr.composeClientKey(user, effectiveID)
enforceUnique := rawClientID != ""
now := time.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
info, exists := cr.clients[key]
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
return key, true
}
if !exists {
info = &ClientInfo{
Key: key,
User: user,
FirstConnectedAt: now,
}
cr.clients[key] = info
} else if info.RunID != "" {
delete(cr.runIndex, info.RunID)
}
info.RawClientID = rawClientID
info.RunID = runID
info.Hostname = hostname
info.IP = remoteAddr
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
info.LastConnectedAt = now
info.DisconnectedAt = time.Time{}
info.Online = true
cr.runIndex[runID] = key
return key, false
}
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
cr.mu.Lock()
defer cr.mu.Unlock()
key, ok := cr.runIndex[runID]
if !ok {
return
}
if info, ok := cr.clients[key]; ok && info.RunID == runID {
if info.RawClientID == "" {
delete(cr.clients, key)
} else {
info.RunID = ""
info.Online = false
now := time.Now()
info.DisconnectedAt = now
}
}
delete(cr.runIndex, runID)
}
// List returns a snapshot of all known clients.
func (cr *ClientRegistry) List() []ClientInfo {
cr.mu.RLock()
defer cr.mu.RUnlock()
result := make([]ClientInfo, 0, len(cr.clients))
for _, info := range cr.clients {
result = append(result, *info)
}
return result
}
// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
// ClientID returns the resolved client identifier for external use.
func (info ClientInfo) ClientID() string {
if info.RawClientID != "" {
return info.RawClientID
}
return info.RunID
}
// GetByRunID retrieves a client by its run ID.
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
key, ok := cr.runIndex[runID]
if !ok {
return ClientInfo{}, false
}
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch {
case user == "":
return id
case id == "":
return user
default:
return fmt.Sprintf("%s.%s", user, id)
}
}

View File

@@ -28,6 +28,7 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -47,11 +48,13 @@ import (
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics"
"github.com/fatedier/frp/server/ports"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
"github.com/fatedier/frp/server/visitor"
)
@@ -96,6 +99,9 @@ type Service struct {
// Manage all controllers
ctlManager *ControlManager
// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
clientRegistry *registry.ClientRegistry
// Manage all proxies
pxyManager *proxy.Manager
@@ -155,9 +161,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
svr := &Service{
ctlManager: NewControlManager(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
ctlManager: NewControlManager(),
clientRegistry: registry.NewClientRegistry(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
rc: &controller.ResourceController{
VisitorManager: visitor.NewManager(),
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
@@ -606,10 +613,23 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
// don't return detailed errors to client
return fmt.Errorf("unexpected error when creating new controller")
}
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
oldCtl.WaitClosed()
}
remoteAddr := ctlConn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
ctl.Close()
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
ctl.clientRegistry = svr.clientRegistry
ctl.Start()
// for statistics
@@ -670,3 +690,42 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware)
subRouter.Use(httppkg.NewRequestLogger)
// metrics
if svr.cfg.EnablePrometheus {
subRouter.Handle("/metrics", promhttp.Handler())
}
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
// apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
).Methods("GET")
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
})
}
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}