Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
+2
View File
@@ -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)
-166
View File
@@ -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)
}
}
+44 -11
View File
@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get)
g.GET("/get/tgId/:tgId", a.getByTgId)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:email", a.getClientLinks)
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
jsonObj(c, resp, nil)
}
func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
return nil, err
}
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
if err != nil {
return nil, err
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
if err != nil {
return nil, err
}
rec.Flow = flow
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
return gin.H{
"client": rec,
"inboundIds": inboundIds,
"externalLinks": externalLinks,
"usedTraffic": usedTraffic,
}, nil
}
func (a *ClientController) get(c *gin.Context) {
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
@@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
jsonObj(c, payload, nil)
}
func (a *ClientController) getByTgId(c *gin.Context) {
tgIdStr := c.Param("tgId")
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
records, err := a.clientService.GetRecordsByTgID(tgId)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
rec.Flow = flow
// Consumed bytes (up+down, including cross-node global overlay) so API
// consumers can pair usage with the client's totalGB quota (#4973).
// Best-effort: a traffic lookup failure must not break the client fetch.
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
results := make([]gin.H, 0, len(records))
for _, rec := range records {
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
results = append(results, payload)
}
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
jsonObj(c, results, nil)
}
func (a *ClientController) create(c *gin.Context) {
+1 -3
View File
@@ -163,7 +163,5 @@ func (a *IndexController) csrfToken(c *gin.Context) {
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()
if err == nil {
jsonObj(c, status, nil)
}
jsonObj(c, status, err)
}
@@ -9,7 +9,7 @@ import (
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
for i := 0; i < loginLimitMaxRecords+100; i++ {
for i := range loginLimitMaxRecords + 100 {
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
}
@@ -28,7 +28,7 @@ func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
limiter.now = func() time.Time { return now }
limiter.mu.Lock()
for i := 0; i < loginLimitMaxRecords-1; i++ {
for i := range loginLimitMaxRecords - 1 {
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
}
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
+5
View File
@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/all", a.getAllSetting)
g.POST("/defaultSettings", a.getDefaultSettings)
g.POST("/factoryDefaults", a.getFactoryDefaults)
g.POST("/update", a.updateSetting)
g.POST("/validateRegex", a.validateRegex)
g.POST("/updateUser", a.updateUser)
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
jsonObj(c, result, nil)
}
func (a *SettingController) getFactoryDefaults(c *gin.Context) {
jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
}
// updateSetting updates all settings with the provided data.
func (a *SettingController) updateSetting(c *gin.Context) {
form, ok := middleware.BindAndValidate[updateSettingForm](c)
+1 -1
View File
@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
trusted = service.DefaultTrustedProxyCIDRs
defer func() {
_ = recover()
}()