diff --git a/pkg/util/http/handler.go b/pkg/util/http/handler.go index d6c7be18..55de2932 100644 --- a/pkg/util/http/handler.go +++ b/pkg/util/http/handler.go @@ -26,6 +26,12 @@ type GeneralResponse struct { Msg string } +type V2Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data"` +} + // APIHandler is a handler function that returns a response object or an error. type APIHandler func(ctx *Context) (any, error) @@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc { } } } + +// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope. +func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := NewContext(w, r) + res, err := handler(ctx) + if err != nil { + log.Warnf("http response [%s]: error: %v", r.URL.Path, err) + code := http.StatusInternalServerError + if e, ok := err.(*Error); ok { + code = e.Code + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res}) + } +} diff --git a/server/api_router.go b/server/api_router.go index bb9e44ed..ff9123f3 100644 --- a/server/api_router.go +++ b/server/api_router.go @@ -48,6 +48,12 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") + subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET") + subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET") + subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET") + subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET") + subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") + // view subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.PathPrefix("/static/").Handler( diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go new file mode 100644 index 00000000..2733a3b0 --- /dev/null +++ b/server/http/controller_v2.go @@ -0,0 +1,376 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "cmp" + "fmt" + "math" + "net/http" + "slices" + "strconv" + "strings" + + 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/server/http/model" +) + +const ( + defaultV2Page = 1 + defaultV2PageSize = 50 + maxV2PageSize = 200 +) + +var apiV2ProxyTypes = []string{ + string(v1.ProxyTypeTCP), + string(v1.ProxyTypeUDP), + string(v1.ProxyTypeHTTP), + string(v1.ProxyTypeHTTPS), + string(v1.ProxyTypeTCPMUX), + string(v1.ProxyTypeSTCP), + string(v1.ProxyTypeXTCP), + string(v1.ProxyTypeSUDP), +} + +// /api/v2/users +func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + if c.clientRegistry == nil { + return nil, fmt.Errorf("client registry unavailable") + } + + userStats := make(map[string]*model.V2UserResp) + for _, info := range c.clientRegistry.List() { + item := getOrCreateV2User(userStats, info.User) + item.ClientCount++ + } + for _, proxyInfo := range c.listV2ProxyStats("") { + item := getOrCreateV2User(userStats, proxyInfo.User) + item.ProxyCount++ + } + + q := strings.ToLower(ctx.Query("q")) + items := make([]model.V2UserResp, 0, len(userStats)) + for _, item := range userStats { + if q != "" && !strings.Contains(strings.ToLower(item.User), q) { + continue + } + items = append(items, *item) + } + slices.SortFunc(items, func(a, b model.V2UserResp) int { + return cmp.Compare(a.User, b.User) + }) + + return buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/clients +func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + if c.clientRegistry == nil { + return nil, fmt.Errorf("client registry unavailable") + } + statusFilter, err := parseV2StatusFilter(ctx.Query("status")) + if err != nil { + return nil, err + } + + userFilter, filterByUser := queryValue(ctx, "user") + clientIDFilter := ctx.Query("clientID") + runIDFilter := ctx.Query("runID") + q := strings.ToLower(ctx.Query("q")) + + records := c.clientRegistry.List() + items := make([]model.ClientInfoResp, 0, len(records)) + for _, info := range records { + if filterByUser && info.User != userFilter { + continue + } + if clientIDFilter != "" && info.ClientID() != clientIDFilter { + continue + } + if runIDFilter != "" && info.RunID != runIDFilter { + continue + } + if !matchV2StatusFilter(info.Online, statusFilter) { + continue + } + resp := buildClientInfoResp(info) + if q != "" && !matchV2ClientQuery(resp, q) { + continue + } + items = append(items, resp) + } + + slices.SortFunc(items, func(a, b model.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 buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/clients/{key} +func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { + return c.APIClientDetail(ctx) +} + +// /api/v2/proxies +func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + statusFilter, err := parseV2StatusFilter(ctx.Query("status")) + if err != nil { + return nil, err + } + + proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type")) + if err != nil { + return nil, err + } + userFilter, filterByUser := queryValue(ctx, "user") + clientIDFilter := ctx.Query("clientID") + q := strings.ToLower(ctx.Query("q")) + + stats := c.listV2ProxyStats(proxyType) + items := make([]model.V2ProxyResp, 0, len(stats)) + for _, ps := range stats { + resp := c.buildV2ProxyResp(ps) + if filterByUser && resp.User != userFilter { + continue + } + if clientIDFilter != "" && resp.ClientID != clientIDFilter { + continue + } + if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) { + continue + } + if q != "" && !matchV2ProxyQuery(resp, q) { + continue + } + items = append(items, resp) + } + + slices.SortFunc(items, func(a, b model.V2ProxyResp) int { + if v := cmp.Compare(a.Type, b.Type); v != 0 { + return v + } + return cmp.Compare(a.Name, b.Name) + }) + + return buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/proxies/{name} +func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, fmt.Errorf("missing proxy name") + } + + ps := mem.StatsCollector.GetProxyByName(name) + if ps == nil { + return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") + } + return c.buildV2ProxyResp(ps), nil +} + +func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp { + item, ok := items[user] + if !ok { + item = &model.V2UserResp{User: user} + items[user] = item + } + return item +} + +func parseV2PageParams(ctx *httppkg.Context) (int, int, error) { + page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page") + if err != nil { + return 0, 0, err + } + pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize") + if err != nil { + return 0, 0, err + } + if pageSize > maxV2PageSize { + return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize)) + } + if page > math.MaxInt/pageSize { + return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large") + } + return page, pageSize, nil +} + +func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) { + if raw == "" { + return defaultValue, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name)) + } + return value, nil +} + +func parseV2StatusFilter(raw string) (string, error) { + status := strings.ToLower(raw) + switch status { + case "", "all", "online", "offline": + return status, nil + default: + return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline") + } +} + +func parseV2ProxyTypeFilter(raw string) (string, error) { + proxyType := strings.ToLower(raw) + if proxyType == "" { + return "", nil + } + if slices.Contains(apiV2ProxyTypes, proxyType) { + return proxyType, nil + } + return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp") +} + +func matchV2StatusFilter(online bool, filter string) bool { + switch filter { + case "", "all": + return true + case "online": + return online + case "offline": + return !online + default: + return true + } +} + +func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] { + total := len(items) + return model.V2PageResp[T]{ + Total: total, + Page: page, + PageSize: pageSize, + Items: paginateV2Items(items, page, pageSize), + } +} + +func paginateV2Items[T any](items []T, page, pageSize int) []T { + start := (page - 1) * pageSize + if start >= len(items) { + return []T{} + } + end := min(start+pageSize, len(items)) + return items[start:end] +} + +func queryValue(ctx *httppkg.Context, key string) (string, bool) { + values, ok := ctx.Req.URL.Query()[key] + if !ok { + return "", false + } + if len(values) == 0 { + return "", true + } + return values[0], true +} + +func matchV2ClientQuery(item model.ClientInfoResp, q string) bool { + return containsV2Query(q, + item.Key, + item.User, + item.ClientID, + item.RunID, + item.Version, + item.WireProtocol, + item.Hostname, + item.ClientIP, + ) +} + +func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { + return containsV2Query(q, + item.Name, + item.Type, + item.User, + item.ClientID, + item.Status.State, + ) +} + +func containsV2Query(q string, values ...string) bool { + for _, value := range values { + if strings.Contains(strings.ToLower(value), q) { + return true + } + } + return false +} + +func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats { + if proxyType != "" { + return mem.StatsCollector.GetProxiesByType(proxyType) + } + + items := make([]*mem.ProxyStats, 0) + for _, t := range apiV2ProxyTypes { + items = append(items, mem.StatsCollector.GetProxiesByType(t)...) + } + return items +} + +func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { + state := "offline" + var spec any + if c.pxyManager != nil { + if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { + state = "online" + spec = getConfFromConfigurer(pxy.GetConfigurer()) + } + } + + return model.V2ProxyResp{ + Name: ps.Name, + Type: ps.Type, + User: ps.User, + ClientID: ps.ClientID, + Spec: spec, + Status: model.V2ProxyStatusResp{ + State: state, + TodayTrafficIn: ps.TodayTrafficIn, + TodayTrafficOut: ps.TodayTrafficOut, + CurConns: ps.CurConns, + LastStartTime: ps.LastStartTime, + LastCloseTime: ps.LastCloseTime, + }, + } +} diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go new file mode 100644 index 00000000..0db98294 --- /dev/null +++ b/server/http/controller_v2_test.go @@ -0,0 +1,294 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + + 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/server/http/model" + serverproxy "github.com/fatedier/frp/server/proxy" + "github.com/fatedier/frp/server/registry" +) + +type v2EnvelopeForTest[T any] struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data T `json:"data"` +} + +type fakeStatsCollector struct { + proxies map[string]*mem.ProxyStats +} + +func (f *fakeStatsCollector) GetServer() *mem.ServerStats { + return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}} +} + +func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats { + items := make([]*mem.ProxyStats, 0) + for _, ps := range f.proxies { + if ps.Type == proxyType { + items = append(items, ps) + } + } + return items +} + +func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats { + ps := f.proxies[proxyName] + if ps != nil && ps.Type == proxyType { + return ps + } + return nil +} + +func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats { + return f.proxies[proxyName] +} + +func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo { + return nil +} + +func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { + return 0, len(f.proxies) +} + +func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1") + if resp.Code != http.StatusOK { + t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) + } + pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if pageResp.Code != http.StatusOK || pageResp.Msg != "success" { + t.Fatalf("envelope mismatch: %#v", pageResp) + } + if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 { + t.Fatalf("page data mismatch: %#v", pageResp.Data) + } + if got := pageResp.Data.Items[0].User; got != "" { + t.Fatalf("first sorted user mismatch, want empty got %q", got) + } + + resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50") + emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" { + t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data) + } + + resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host") + aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" { + t.Fatalf("alice filter mismatch: %#v", aliceResp.Data) + } + + resp = performRequest(router, "/api/v2/clients?status=offline") + offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" { + t.Fatalf("offline filter mismatch: %#v", offlineResp.Data) + } +} + +func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients?page=0") + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Data != nil { + t.Fatalf("error envelope mismatch: %#v", errResp) + } + + resp = performRequest(router, "/api/v2/clients?pageSize=201") + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + + resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code) + } +} + +func TestAPIV2ClientDetailEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients/alice.client-a") + if resp.Code != http.StatusOK { + t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) + } + detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp) + if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" { + t.Fatalf("client detail mismatch: %#v", detailResp.Data) + } +} + +func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/proxies?type=invalid") + if resp.Code != http.StatusBadRequest { + t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Data != nil { + t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp) + } + + resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50") + proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp) + if proxyResp.Data.Total != 1 { + t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data) + } + proxyItem := proxyResp.Data.Items[0] + if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { + t.Fatalf("proxy item mismatch: %#v", proxyItem) + } + + resp = performRequest(router, "/api/v2/proxies/tcp-alice") + proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp) + if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { + t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) + } + + resp = performRequest(router, "/api/v2/users?page=1&pageSize=50") + userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp) + if userResp.Data.Total != 3 { + t.Fatalf("user total mismatch: %#v", userResp.Data) + } + for _, item := range userResp.Data.Items { + if item.ClientCount != 1 || item.ProxyCount != 1 { + t.Fatalf("user counts mismatch: %#v", item) + } + } +} + +func TestLegacyAPIResponsesRemainBare(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/clients") + var clients []model.ClientInfoResp + if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil { + t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String()) + } + if len(clients) != 3 { + t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients)) + } + + resp = performRequest(router, "/api/proxy/tcp") + var proxies model.GetProxyInfoResp + if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil { + t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String()) + } + if len(proxies.Proxies) != 2 { + t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies)) + } + var envelope httppkg.V2Response + if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 { + t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope) + } +} + +func newV2TestController(t *testing.T) *Controller { + t.Helper() + + oldStatsCollector := mem.StatsCollector + mem.StatsCollector = &fakeStatsCollector{ + proxies: map[string]*mem.ProxyStats{ + "tcp-empty": { + Name: "tcp-empty", + Type: "tcp", + User: "", + ClientID: "legacy-client", + TodayTrafficIn: 10, + TodayTrafficOut: 20, + CurConns: 1, + }, + "tcp-alice": { + Name: "tcp-alice", + Type: "tcp", + User: "alice", + ClientID: "client-a", + TodayTrafficIn: 30, + TodayTrafficOut: 40, + }, + "udp-bob": { + Name: "udp-bob", + Type: "udp", + User: "bob", + ClientID: "client-b", + }, + }, + } + t.Cleanup(func() { + mem.StatsCollector = oldStatsCollector + }) + + clientRegistry := registry.NewClientRegistry() + clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1") + clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2") + clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1") + clientRegistry.MarkOfflineByRunID("run-b") + + return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager()) +} + +func newV2TestRouter(controller *Controller) *mux.Router { + router := mux.NewRouter() + router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet) + router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet) + router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet) + router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet) + router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet) + router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet) + router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet) + return router +} + +func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + return resp +} + +func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T { + t.Helper() + + var out T + if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil { + t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String()) + } + return out +} diff --git a/server/http/model/v2.go b/server/http/model/v2.go new file mode 100644 index 00000000..edd3212d --- /dev/null +++ b/server/http/model/v2.go @@ -0,0 +1,46 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +type V2PageResp[T any] struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Items []T `json:"items"` +} + +type V2UserResp struct { + User string `json:"user"` + ClientCount int `json:"clientCount"` + ProxyCount int `json:"proxyCount"` +} + +type V2ProxyResp struct { + Name string `json:"name"` + Type string `json:"type"` + User string `json:"user"` + ClientID string `json:"clientID"` + Spec any `json:"spec"` + Status V2ProxyStatusResp `json:"status"` +} + +type V2ProxyStatusResp struct { + State string `json:"phase"` + TodayTrafficIn int64 `json:"todayTrafficIn"` + TodayTrafficOut int64 `json:"todayTrafficOut"` + CurConns int64 `json:"curConns"` + LastStartTime string `json:"lastStartTime"` + LastCloseTime string `json:"lastCloseTime"` +}