mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
After the multi-inbound client migration, client state belongs to the client API surface, not the inbound one. Twelve routes that were crammed under /panel/api/inbounds/* now live where they belong, under /panel/api/clients/*. Moved (route, handler, doc): POST /clientIps/:email POST /clearClientIps/:email POST /onlines POST /lastOnline POST /updateClientTraffic/:email POST /resetAllClientTraffics/:id POST /delDepletedClients/:id POST /:id/resetClientTraffic/:email GET /getClientTraffics/:email GET /getClientTrafficsById/:id GET /getSubLinks/:subId GET /getClientLinks/:id/:email Their /clients/* counterparts are: POST /clients/clientIps/:email POST /clients/clearClientIps/:email POST /clients/onlines POST /clients/lastOnline POST /clients/updateTraffic/:email POST /clients/resetTraffic/:email (email-only, fans out) GET /clients/traffic/:email GET /clients/traffic/byId/:id GET /clients/subLinks/:subId GET /clients/links/:id/:email per-inbound resetAllClientTraffics and delDepletedClients are dropped entirely — the Clients page already exposes global Reset All Traffic and Delete depleted actions, and per-inbound resets are meaningless once a client can be attached to many inbounds. ClientService.ResetTrafficByEmail is the new email-only reset path: it looks up every inbound the client is attached to and pushes the counter reset + Xray re-add through inboundService.ResetClientTraffic for each one, so depleted users come back online instantly. Frontend callers (ClientsPage, useClients, ClientQrModal, ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all switched to the new paths. The Inbounds page drops its per-inbound "Reset client traffic" and "Delete depleted clients" dropdown items — users do those at the client level now. api-docs is rebuilt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
375 lines
12 KiB
Go
375 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/web/session"
|
|
"github.com/mhsanaei/3x-ui/v3/web/websocket"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// InboundController handles HTTP requests related to Xray inbounds management.
|
|
type InboundController struct {
|
|
inboundService service.InboundService
|
|
xrayService service.XrayService
|
|
fallbackService service.FallbackService
|
|
}
|
|
|
|
// NewInboundController creates a new InboundController and sets up its routes.
|
|
func NewInboundController(g *gin.RouterGroup) *InboundController {
|
|
a := &InboundController{}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
|
|
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
|
|
// Mirrors the same heuristic used by the periodic traffic job.
|
|
const broadcastInboundsUpdateClientLimit = 5000
|
|
|
|
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
|
|
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
|
|
// so we send an invalidate signal instead — frontend re-fetches via REST.
|
|
// Skipped entirely when no WebSocket clients are connected.
|
|
func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
|
if !websocket.HasClients() {
|
|
return
|
|
}
|
|
inbounds, err := a.inboundService.GetInbounds(userId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
totalClients := 0
|
|
for _, ib := range inbounds {
|
|
totalClients += len(ib.ClientStats)
|
|
}
|
|
if totalClients > broadcastInboundsUpdateClientLimit {
|
|
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
|
return
|
|
}
|
|
websocket.BroadcastInbounds(inbounds)
|
|
}
|
|
|
|
// initRouter initializes the routes for inbound-related operations.
|
|
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|
|
|
g.GET("/list", a.getInbounds)
|
|
g.GET("/get/:id", a.getInbound)
|
|
|
|
g.POST("/add", a.addInbound)
|
|
g.POST("/del/:id", a.delInbound)
|
|
g.POST("/update/:id", a.updateInbound)
|
|
g.POST("/setEnable/:id", a.setInboundEnable)
|
|
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
|
g.POST("/import", a.importInbound)
|
|
g.GET("/:id/fallbackChildren", a.getFallbackChildren)
|
|
g.POST("/:id/fallbackChildren", a.setFallbackChildren)
|
|
}
|
|
|
|
// getInbounds retrieves the list of inbounds for the logged-in user.
|
|
func (a *InboundController) getInbounds(c *gin.Context) {
|
|
user := session.GetLoginUser(c)
|
|
inbounds, err := a.inboundService.GetInbounds(user.Id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, inbounds, nil)
|
|
}
|
|
|
|
// getInbound retrieves a specific inbound by its ID.
|
|
func (a *InboundController) getInbound(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
inbound, err := a.inboundService.GetInbound(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, inbound, nil)
|
|
}
|
|
|
|
// addInbound creates a new inbound configuration.
|
|
func (a *InboundController) addInbound(c *gin.Context) {
|
|
inbound := &model.Inbound{}
|
|
err := c.ShouldBind(inbound)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
|
|
return
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
inbound.UserId = user.Id
|
|
// Treat NodeID=0 as "no node" — gin's *int form binding can land on
|
|
// 0 when the field is absent or empty, and 0 is never a valid Node
|
|
// row id. Without this normalization the runtime layer would try to
|
|
// load Node id=0 and surface "record not found".
|
|
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
|
inbound.NodeID = nil
|
|
}
|
|
// When the central panel deploys an inbound to a remote node, it sends
|
|
// the Tag pre-computed (so both DBs agree on the identifier). Local
|
|
// UI submits don't include a Tag — we compute one from listen+port
|
|
// using the original collision-avoiding scheme.
|
|
if inbound.Tag == "" {
|
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
|
} else {
|
|
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
|
}
|
|
}
|
|
|
|
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
a.broadcastInboundsUpdate(user.Id)
|
|
}
|
|
|
|
// delInbound deletes an inbound configuration by its ID.
|
|
func (a *InboundController) delInbound(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
|
|
return
|
|
}
|
|
needRestart, err := a.inboundService.DelInbound(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
a.broadcastInboundsUpdate(user.Id)
|
|
}
|
|
|
|
// updateInbound updates an existing inbound configuration.
|
|
func (a *InboundController) updateInbound(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
return
|
|
}
|
|
inbound := &model.Inbound{
|
|
Id: id,
|
|
}
|
|
err = c.ShouldBind(inbound)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
return
|
|
}
|
|
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
|
|
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
|
|
// support migrating an inbound between nodes), but normalising here
|
|
// keeps the wire shape consistent.
|
|
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
|
inbound.NodeID = nil
|
|
}
|
|
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
a.broadcastInboundsUpdate(user.Id)
|
|
}
|
|
|
|
// setInboundEnable flips only the enable flag of an inbound. This is a
|
|
// dedicated endpoint because the regular update path serialises the entire
|
|
// settings JSON (every client) — far too heavy for an interactive switch
|
|
// on inbounds with thousands of clients. Frontend optimistically updates
|
|
// the UI; we just persist + sync xray + nudge other open admin sessions.
|
|
func (a *InboundController) setInboundEnable(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
return
|
|
}
|
|
type form struct {
|
|
Enable bool `json:"enable" form:"enable"`
|
|
}
|
|
var f form
|
|
if err := c.ShouldBind(&f); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
|
// instead of fetching + serialising the whole inbound list. Other open
|
|
// sessions re-fetch via REST. The toggling admin's own UI already
|
|
// updated optimistically.
|
|
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
|
}
|
|
|
|
// resetInboundTraffic resets traffic counters for a specific inbound.
|
|
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
return
|
|
}
|
|
|
|
err = a.inboundService.ResetInboundTraffic(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
} else {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
|
}
|
|
|
|
// resetAllTraffics resets all traffic counters across all inbounds.
|
|
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
|
err := a.inboundService.ResetAllTraffics()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
} else {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
|
}
|
|
|
|
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
|
|
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
return
|
|
}
|
|
|
|
err = a.inboundService.ResetAllClientTraffics(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
} else {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
|
}
|
|
|
|
// importInbound imports an inbound configuration from provided data.
|
|
func (a *InboundController) importInbound(c *gin.Context) {
|
|
inbound := &model.Inbound{}
|
|
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
inbound.Id = 0
|
|
inbound.UserId = user.Id
|
|
if inbound.Tag == "" {
|
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
|
} else {
|
|
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
|
}
|
|
}
|
|
|
|
for index := range inbound.ClientStats {
|
|
inbound.ClientStats[index].Id = 0
|
|
inbound.ClientStats[index].Enable = true
|
|
}
|
|
|
|
needRestart := false
|
|
inbound, needRestart, err = a.inboundService.AddInbound(inbound)
|
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, err)
|
|
if err == nil && needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
}
|
|
|
|
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
|
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
|
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
|
// controller layer means the service interface stays HTTP-agnostic — service
|
|
// methods receive a plain host string instead of a *gin.Context.
|
|
func resolveHost(c *gin.Context) string {
|
|
if isTrustedForwardedRequest(c) {
|
|
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
|
if i := strings.Index(h, ","); i >= 0 {
|
|
h = strings.TrimSpace(h[:i])
|
|
}
|
|
if hp, _, err := net.SplitHostPort(h); err == nil {
|
|
return hp
|
|
}
|
|
return h
|
|
}
|
|
if h := c.GetHeader("X-Real-IP"); h != "" {
|
|
return h
|
|
}
|
|
}
|
|
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
|
return h
|
|
}
|
|
return c.Request.Host
|
|
}
|
|
|
|
func (a *InboundController) getFallbackChildren(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
rows, err := a.fallbackService.GetChildren(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
jsonObj(c, rows, nil)
|
|
}
|
|
|
|
func (a *InboundController) setFallbackChildren(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
type body struct {
|
|
Children []service.FallbackChildInput `json:"children"`
|
|
}
|
|
var b body
|
|
if err := c.ShouldBindJSON(&b); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if err := a.fallbackService.SetChildren(id, b.Children); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
a.xrayService.SetToNeedRestart()
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
|
}
|
|
|