Per user decision 2026-10-21 (reverses the earlier keep call in PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local core, so measuring/probing from the panel host is meaningless. Backend: - delete service/outbound probe implementation (probe_http.go and its tests) and the TestOutbound/TestOutbounds batch prober; outbound traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged - drop POST /panel/api/xray/testOutbound + testOutbounds routes and the outboundTestUrl setting (get/update plumbing removed; stored rows in existing DBs are inert) - drop POST /panel/api/server/restartXrayService + stopXrayService routes/handlers and the node-sync allowlist entry; the service-level Stop/RestartXrayService stay for internal import/restore flows - node-sync allowlist contract test updated to the new inventory Frontend: - remove test buttons/mode switch/test-all from outbounds tab, card list and table columns; delete TestResultPopover and CountryPill; drop the egress/country/latency columns fed by probe results - remove outboundTestUrl state and save plumbing from useXraySetting and the Basics tab field; drop OutboundTestResult schemas - delete the 15 now-dead i18n keys from all 13 locales (incl. the restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green) - tests: drop the test-URL hook test, re-probe the loopback index mapping through Move-to-top, regenerate API types (gen)
212 lines
7.1 KiB
Go
212 lines
7.1 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
|
|
type APIController struct {
|
|
BaseController
|
|
inboundController *InboundController
|
|
serverController *ServerController
|
|
nodeController *NodeController
|
|
agentController *AgentController
|
|
hostController *HostController
|
|
settingController *SettingController
|
|
xraySettingController *XraySettingController
|
|
userService panel.UserService
|
|
apiTokenService panel.ApiTokenService
|
|
}
|
|
|
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
|
func NewAPIController(g *gin.RouterGroup) *APIController {
|
|
a := &APIController{}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|
// A verified client certificate (a completed mTLS handshake) authenticates
|
|
// the caller, equivalent to a valid bearer token. api_authed must be set so
|
|
// the CSRF middleware lets cert-authed mutations through.
|
|
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
|
|
if u, err := a.userService.GetFirstUser(); err == nil {
|
|
session.SetAPIAuthUser(c, u)
|
|
}
|
|
c.Set("api_authed", true)
|
|
c.Set("api_token_scope", model.ApiScopeNodeSync)
|
|
c.Next()
|
|
return
|
|
}
|
|
auth := c.GetHeader("Authorization")
|
|
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
|
tok := after
|
|
if row, ok := a.apiTokenService.MatchToken(tok); ok {
|
|
if u, err := a.userService.GetFirstUser(); err == nil {
|
|
session.SetAPIAuthUser(c, u)
|
|
}
|
|
c.Set("api_authed", true)
|
|
c.Set("api_token_scope", row.Scope)
|
|
c.Next()
|
|
return
|
|
}
|
|
}
|
|
if !session.IsLogin(c) {
|
|
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
|
|
c.AbortWithStatus(http.StatusUnauthorized)
|
|
} else {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
}
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
|
|
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
|
|
// Keys are route patterns relative to /panel/api.
|
|
var monitorScopeAllow = map[string]struct{}{
|
|
"/server/status": {},
|
|
"/server/getPanelUpdateInfo": {},
|
|
"/nodes/history/:id/:metric/:bucket": {},
|
|
}
|
|
|
|
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
|
|
// /panel/api; Gin patterns prevent concrete parameters broadening authority.
|
|
var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
|
"/server/status": {http.MethodGet: {}},
|
|
"/inbounds/list": {http.MethodGet: {}},
|
|
"/inbounds/add": {http.MethodPost: {}},
|
|
"/inbounds/del/:id": {http.MethodPost: {}},
|
|
"/inbounds/update/:id": {http.MethodPost: {}},
|
|
"/clients/add": {http.MethodPost: {}},
|
|
"/clients/del/:email": {http.MethodPost: {}},
|
|
"/clients/:email/detach": {http.MethodPost: {}},
|
|
"/clients/update/:email": {http.MethodPost: {}},
|
|
"/server/getWebCertFiles": {http.MethodGet: {}},
|
|
"/server/descendants": {http.MethodGet: {}},
|
|
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
|
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
|
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
|
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
|
"/clients/onlines": {http.MethodPost: {}},
|
|
"/clients/lastOnline": {http.MethodPost: {}},
|
|
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
|
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
|
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
|
"/hosts/list": {http.MethodGet: {}},
|
|
}
|
|
|
|
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
|
|
// Admin tokens and session-login users retain their existing behavior.
|
|
func (a *APIController) enforceTokenScope(c *gin.Context) {
|
|
scopeVal, ok := c.Get("api_token_scope")
|
|
if !ok {
|
|
c.Next()
|
|
return
|
|
}
|
|
scope, _ := scopeVal.(string)
|
|
if scope == model.ApiScopeAdmin {
|
|
c.Next()
|
|
return
|
|
}
|
|
deny := func() {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
"success": false,
|
|
"msg": "this API token is not permitted to access this endpoint",
|
|
})
|
|
}
|
|
rel := relAPIPath(c.FullPath())
|
|
switch scope {
|
|
case model.ApiScopeMonitor:
|
|
if _, allowed := monitorScopeAllow[rel]; allowed && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) {
|
|
c.Next()
|
|
return
|
|
}
|
|
case model.ApiScopeNodeSync:
|
|
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
|
|
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
|
|
c.Next()
|
|
return
|
|
}
|
|
}
|
|
default:
|
|
deny()
|
|
return
|
|
}
|
|
deny()
|
|
}
|
|
|
|
func relAPIPath(fullPath string) string {
|
|
const marker = "/panel/api"
|
|
_, after, ok := strings.Cut(fullPath, marker)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return after
|
|
}
|
|
|
|
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
|
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|
// Main API group
|
|
api := g.Group("/panel/api")
|
|
api.Use(a.checkAPIAuth)
|
|
api.Use(a.enforceTokenScope)
|
|
// Decode + verify the node config envelope (zstd + X-Config-Sha256) and
|
|
// advertise support, before CSRF/handlers read the body.
|
|
api.Use(middleware.ConfigEnvelopeMiddleware())
|
|
api.Use(middleware.CSRFMiddleware())
|
|
|
|
// Inbounds API
|
|
inbounds := api.Group("/inbounds")
|
|
a.inboundController = NewInboundController(inbounds)
|
|
|
|
clients := api.Group("/clients")
|
|
NewClientController(clients)
|
|
|
|
// Server API
|
|
server := api.Group("/server")
|
|
a.serverController = NewServerController(server)
|
|
|
|
// Nodes API — multi-panel management
|
|
nodes := api.Group("/nodes")
|
|
a.nodeController = NewNodeController(nodes)
|
|
|
|
// Agent API — lightweight agent mode (no web panel on node).
|
|
// The agent endpoints (register/heartbeat/config/traffic) authenticate with
|
|
// the agent's own Bearer token, which is NOT a panel API token, so they are
|
|
// mounted on a public group and must not pass through checkAPIAuth (it would
|
|
// 404 every agent call: no session, no panel token).
|
|
agentPub := g.Group("/panel/api/agent")
|
|
a.agentController = NewAgentController(agentPub)
|
|
|
|
// Agent administration — panel-authenticated (session login or panel API
|
|
// token via the group's checkAPIAuth): token issuance and per-agent egress
|
|
// management. Agent Bearer tokens cannot pass checkAPIAuth, so agents can
|
|
// never reach these.
|
|
agentAdmin := api.Group("/agent")
|
|
a.agentController.RegisterAdminRoutes(agentAdmin)
|
|
|
|
// Hosts API — per-inbound override endpoints for subscription links
|
|
hosts := api.Group("/hosts")
|
|
a.hostController = NewHostController(hosts)
|
|
|
|
// Settings + Xray config management live under the API surface too, so the
|
|
// same API token drives them. Paths are /panel/api/setting/* and
|
|
// /panel/api/xray/*.
|
|
a.settingController = NewSettingController(api)
|
|
a.xraySettingController = NewXraySettingController(api)
|
|
|
|
// Subscription balancers — client-side balancers for the JSON sub output
|
|
NewSubBalancerController(api)
|
|
|
|
// Extra routes
|
|
}
|