mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-01 12:52:14 +03:00
fix(api): authenticate GET /panel/api/openapi.json + pin the route registry to the router (#6133)
* test(web): pin the endpoints.ts registry to the actual Gin routes endpoints.ts is a hand-maintained registry and nothing checked it against the router: an omitted API route silently vanishes from the generated OpenAPI docs, and an entry for a removed route documents an endpoint that 404s. Two new tests construct the real router against a throwaway DB and diff the /panel/api surface both ways. The check found one gap on arrival: GET /panel/api/openapi.json — the endpoint that serves the docs — was itself undocumented. Registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one Three things from the review, in severity order. The bot found that GET /panel/api/openapi.json was registered on the base-path group one line before the /panel/api group installs checkAPIAuth, so Gin's snapshot of the parent chain meant the whole admin API surface plus build version was fetchable without a session — while this very PR was about to document it as auth-required. Move the registration inside the authed api group. Verified: unauthenticated it now 404s exactly like server/status (was 200), and a logged-in session still serves it 200, so the docs page is unaffected. The existing api_docs_test.go already checked the forward direction by regex-scanning controller source against a hand-maintained per-file path switch — which is why it missed this web.go-registered route, and whose fall-through default silently mis-paths any unlisted controller file. The new router-based test is a strict superset, so fold in the extra surface it guarded (/login, /logout, /csrf-token, /getTwoFactorEnable, /ws) and delete the old test rather than run two. Harden the endpoints.ts parser: pair each method with the next path sequentially instead of a brace-crossing regex, and fail loudly when the parsed count doesn't match the declared method fields. Construct the server once across both subtests, cancel it, and restore the previous global on cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3980,6 +3980,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/openapi.json": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
|
||||
"operationId": "get_panel_api_openapi_json",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/openapi.json',
|
||||
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/status',
|
||||
|
||||
@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
api.Use(middleware.ConfigEnvelopeMiddleware())
|
||||
api.Use(middleware.CSRFMiddleware())
|
||||
|
||||
api.GET("/openapi.json", ServeOpenAPISpec)
|
||||
|
||||
// Inbounds API
|
||||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type routeDef struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
||||
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
||||
|
||||
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
|
||||
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
||||
|
||||
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
|
||||
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
||||
// placeholders (paths starting with /{...}) are skipped because they aren't
|
||||
// registered on the main Gin engine.
|
||||
func buildDocSet(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
controllerDir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current dir: %v", err)
|
||||
}
|
||||
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
|
||||
data, err := os.ReadFile(endpointsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
|
||||
}
|
||||
docSet := make(map[string]bool)
|
||||
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
||||
method, path := m[1], m[2]
|
||||
if method == "WS" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
||||
continue
|
||||
}
|
||||
docSet[method+" "+path] = true
|
||||
}
|
||||
if len(docSet) == 0 {
|
||||
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
||||
}
|
||||
return docSet
|
||||
}
|
||||
|
||||
func TestAPIRoutesDocumented(t *testing.T) {
|
||||
docSet := buildDocSet(t)
|
||||
|
||||
controllerDir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current dir: %v", err)
|
||||
}
|
||||
|
||||
var allRoutes []routeDef
|
||||
|
||||
entries, err := os.ReadDir(controllerDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read controller dir: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read %s: %v", entry.Name(), err)
|
||||
}
|
||||
src := string(data)
|
||||
|
||||
// Determine the base path for this file based on its initRouter patterns
|
||||
basePath := ""
|
||||
switch entry.Name() {
|
||||
case "index.go":
|
||||
basePath = ""
|
||||
case "spa.go":
|
||||
basePath = "/panel"
|
||||
case "api.go":
|
||||
basePath = "/panel/api"
|
||||
case "inbound.go":
|
||||
basePath = "/panel/api/inbounds"
|
||||
case "client.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "group.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "server.go":
|
||||
basePath = "/panel/api/server"
|
||||
case "node.go":
|
||||
basePath = "/panel/api/nodes"
|
||||
case "host.go":
|
||||
basePath = "/panel/api/hosts"
|
||||
case "setting.go":
|
||||
basePath = "/panel/api/setting"
|
||||
case "xray_setting.go":
|
||||
basePath = "/panel/api/xray"
|
||||
case "websocket.go":
|
||||
basePath = ""
|
||||
}
|
||||
|
||||
// Find all route registrations
|
||||
matches := routePattern.FindAllStringSubmatch(src, -1)
|
||||
for _, m := range matches {
|
||||
method := m[2]
|
||||
path := strings.TrimSpace(m[3])
|
||||
if basePath == "" {
|
||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
|
||||
} else {
|
||||
fullPath := basePath + path
|
||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
||||
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
||||
|
||||
missingFromDocs := 0
|
||||
foundInDoc := 0
|
||||
sourceSet := make(map[string]bool)
|
||||
|
||||
for _, r := range allRoutes {
|
||||
key := r.Method + " " + r.Path
|
||||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||
spaPages := map[string]bool{
|
||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||
"/panel/clients": true, "/panel/groups": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/xray": true, "/panel/outbound": true,
|
||||
"/panel/routing": true, "/panel/api-docs": true,
|
||||
}
|
||||
if spaPages[r.Path] {
|
||||
continue
|
||||
}
|
||||
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
||||
if r.Path == "/panel/csrf-token" {
|
||||
continue
|
||||
}
|
||||
// Skip Chrome DevTools route
|
||||
if strings.Contains(r.Path, ".well-known") {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceSet[key] = true
|
||||
if docSet[key] {
|
||||
foundInDoc++
|
||||
} else {
|
||||
missingFromDocs++
|
||||
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
||||
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
||||
|
||||
if missingFromDocs > 0 {
|
||||
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
|
||||
}
|
||||
}
|
||||
139
internal/web/routes_contract_test.go
Normal file
139
internal/web/routes_contract_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
)
|
||||
|
||||
/*
|
||||
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
|
||||
API route omitted there silently vanishes from the generated OpenAPI docs,
|
||||
and an entry for a removed route documents an endpoint that 404s. This test
|
||||
constructs the real router and diffs it against the registry both ways.
|
||||
|
||||
Scope: everything under /panel/api/ plus the session-auth surface the
|
||||
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
|
||||
/ws). SPA page routes are UI, not API, and stay out; registry paths that
|
||||
start with "/{" describe the standalone subscription server, which this
|
||||
engine does not serve.
|
||||
*/
|
||||
|
||||
var contractExtraRoutes = map[string]bool{
|
||||
"POST /login": true,
|
||||
"POST /logout": true,
|
||||
"GET /csrf-token": true,
|
||||
"POST /getTwoFactorEnable": true,
|
||||
"GET /ws": true,
|
||||
}
|
||||
|
||||
func inContractScope(method, path string) bool {
|
||||
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
|
||||
}
|
||||
|
||||
func registeredContractRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
previous := global.GetWebServer()
|
||||
s := NewServer()
|
||||
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
|
||||
global.SetWebServer(s)
|
||||
t.Cleanup(func() {
|
||||
s.cancel()
|
||||
global.SetWebServer(previous)
|
||||
})
|
||||
|
||||
engine, err := s.initRouter()
|
||||
if err != nil {
|
||||
t.Fatalf("init router: %v", err)
|
||||
}
|
||||
routes := make(map[string]bool)
|
||||
for _, r := range engine.Routes() {
|
||||
routes[r.Method+" "+r.Path] = true
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatal("no routes registered; router construction is broken")
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func documentedContractRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
|
||||
if err != nil {
|
||||
t.Fatalf("read endpoints.ts: %v", err)
|
||||
}
|
||||
text := string(source)
|
||||
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
|
||||
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
|
||||
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
|
||||
if declared := strings.Count(text, "method: '"); len(methods) != declared {
|
||||
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
|
||||
}
|
||||
docs := make(map[string]bool)
|
||||
for i, m := range methods {
|
||||
segmentEnd := len(text)
|
||||
if i+1 < len(methods) {
|
||||
segmentEnd = methods[i+1][0]
|
||||
}
|
||||
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
|
||||
if pathMatch == nil {
|
||||
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
|
||||
}
|
||||
method := text[m[2]:m[3]]
|
||||
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
|
||||
continue
|
||||
}
|
||||
docs[method+" "+pathMatch[1]] = true
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func TestRouteRegistryContract(t *testing.T) {
|
||||
registered := registeredContractRoutes(t)
|
||||
documented := documentedContractRoutes(t)
|
||||
|
||||
t.Run("every API route is documented", func(t *testing.T) {
|
||||
var missing []string
|
||||
for route := range registered {
|
||||
fields := strings.Fields(route)
|
||||
if inContractScope(fields[0], fields[1]) && !documented[route] {
|
||||
missing = append(missing, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
for _, route := range missing {
|
||||
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every documented route is registered", func(t *testing.T) {
|
||||
var stale []string
|
||||
for route := range documented {
|
||||
if !registered[route] {
|
||||
stale = append(stale, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
for _, route := range stale {
|
||||
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -247,7 +247,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
|
||||
s.index = controller.NewIndexController(g)
|
||||
s.panel = controller.NewXUIController(g)
|
||||
g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
|
||||
s.api = controller.NewAPIController(g)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
|
||||
Reference in New Issue
Block a user