mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 12:17:14 +00:00
47d2303334
An inbound could be saved with security "tls" and a certificate row carrying neither a file path nor inline content. Nothing rejected it, so the row reached xray-core, whose readFileOrString fails with "both file and bytes are empty" and takes the whole config build down with it — every other inbound included. Validate the credentials on both sides of the wire. validateInboundTLSCertificates follows xray's file-over-inline precedence, requires a private key for every non-verify certificate and insists on at least one server certificate, so a verify-only CA list no longer passes as a server config. The inbound form's Zod schema enforces the same rules per field and serializes only the editor mode the operator actually used, and a failed save jumps to the Security tab naming the certificate row that broke. On update the guard is scoped to a real TLS edit. A row already stored incomplete is grandfathered: it stays editable, and only a save that breaks a previously valid block is refused. A sub-node stores whatever the master pushes, and Remote.UpdateInbound falls back to AddInbound when the node does not yet hold the tag, so a grandfathered row could otherwise never be deployed or re-seeded — the rejection is swallowed to a logger.Debug line and the node stays on a stale config while the panel shows the client as cut off. The controller now marks a node-sync request (mTLS or a node-sync token) on a per-request copy of InboundService, and the guard steps aside for it on both add and update: the row was judged where the operator acted, and a node that refuses it only falls out of sync. Operator and admin-token saves are held to the guard as before. The security union is parameterised on its tlsSettings branch instead of copied, and tlsCertUsesFiles is the one file-vs-inline inference shared by the form schema and the adapter, so the mode the editor opens in and the pair of fields the save serializes cannot drift apart.
509 lines
16 KiB
Go
509 lines
16 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"strconv"
|
|
"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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// InboundController handles HTTP requests related to Xray inbounds management.
|
|
type InboundController struct {
|
|
inboundService service.InboundService
|
|
clientService service.ClientService
|
|
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)
|
|
}
|
|
|
|
// inboundServiceFor tells the service whether this request is a master's
|
|
// node-sync push, so the node stores the row instead of re-judging it.
|
|
func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
|
|
svc := a.inboundService
|
|
scope, _ := c.Get("api_token_scope")
|
|
svc.FromNodeSync = scope == model.ApiScopeNodeSync
|
|
return &svc
|
|
}
|
|
|
|
// initRouter initializes the routes for inbound-related operations.
|
|
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|
g.GET("/list", a.getInbounds)
|
|
g.GET("/list/slim", a.getInboundsSlim)
|
|
g.GET("/options", a.getInboundOptions)
|
|
g.GET("/allLinks", a.getAllInboundLinks)
|
|
g.GET("/get/:id", a.getInbound)
|
|
g.GET("/:id/fallbacks", a.getFallbacks)
|
|
|
|
g.POST("/add", a.addInbound)
|
|
g.POST("/del/:id", a.delInbound)
|
|
g.POST("/bulkDel", a.bulkDelInbounds)
|
|
g.POST("/update/:id", a.updateInbound)
|
|
g.POST("/setEnable/:id", a.setInboundEnable)
|
|
g.POST("/:id/subSortIndex", a.setInboundSubSortIndex)
|
|
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
|
g.POST("/:id/delAllClients", a.delAllInboundClients)
|
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
|
g.POST("/import", a.importInbound)
|
|
g.POST("/:id/fallbacks", a.setFallbacks)
|
|
g.POST("/pushClientTraffics", a.pushClientTraffics)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// getInboundsSlim is the list-page variant that strips full client
|
|
// payloads from settings.clients[]. Detail-view flows still use /get/:id.
|
|
func (a *InboundController) getInboundsSlim(c *gin.Context) {
|
|
user := session.GetLoginUser(c)
|
|
inbounds, err := a.inboundService.GetInboundsSlim(user.Id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, inbounds, nil)
|
|
}
|
|
|
|
// getAllInboundLinks returns every inbound's share links across all clients,
|
|
// rendered through the same subscription engine the client pages use so the
|
|
// remark template (name-only display part) is applied consistently.
|
|
func (a *InboundController) getAllInboundLinks(c *gin.Context) {
|
|
user := session.GetLoginUser(c)
|
|
links, err := a.inboundService.GetAllInboundLinks(resolveHost(c), user.Id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, links, nil)
|
|
}
|
|
|
|
// getInboundOptions returns a lightweight projection of the user's inbounds
|
|
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
|
|
// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
|
|
func (a *InboundController) getInboundOptions(c *gin.Context) {
|
|
user := session.GetLoginUser(c)
|
|
options, err := a.inboundService.GetInboundOptions(user.Id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, options, 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.GetInboundDetail(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, ok := middleware.BindAndValidate[model.Inbound](c)
|
|
if !ok {
|
|
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
|
|
}
|
|
|
|
inbound, needRestart, err := a.inboundServiceFor(c).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)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
// 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)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
type bulkDelInboundsRequest struct {
|
|
Ids []int `json:"ids"`
|
|
}
|
|
|
|
// bulkDelInbounds deletes several inbounds in one call. Failures are
|
|
// reported per id and the rest still proceed; xray restarts at most once.
|
|
func (a *InboundController) bulkDelInbounds(c *gin.Context) {
|
|
var req bulkDelInboundsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
result, needRestart, err := a.inboundService.DelInbounds(req.Ids)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, result, nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
a.broadcastInboundsUpdate(user.Id)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
if !middleware.BindAndValidateInto(c, inbound) {
|
|
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.inboundServiceFor(c).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)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
// setInboundSubSortIndex changes only subscription ordering without sending
|
|
// the inbound's settings/client payload.
|
|
func (a *InboundController) setInboundSubSortIndex(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 {
|
|
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required,min=1"`
|
|
}
|
|
var f form
|
|
if err := c.ShouldBind(&f); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if err := a.inboundService.SetInboundSubSortIndex(id, f.SubSortIndex); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
|
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// delAllInboundClients removes every client attached to a specific inbound
|
|
// while keeping the inbound itself. Internally collects the current email
|
|
// list from settings.clients[] and feeds it into ClientService.BulkDelete,
|
|
// which handles per-inbound JSON rewriting, runtime user removal, traffic
|
|
// row cleanup, and the SyncInbound mapping pass in one optimized cycle.
|
|
func (a *InboundController) delAllInboundClients(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
emails, err := a.inboundService.EmailsByInbound(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if len(emails) == 0 {
|
|
jsonObj(c, service.BulkDeleteResult{}, nil)
|
|
return
|
|
}
|
|
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, emails, false)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, result, nil)
|
|
if needRestart {
|
|
a.xrayService.SetToNeedRestart()
|
|
}
|
|
user := session.GetLoginUser(c)
|
|
a.broadcastInboundsUpdate(user.Id)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// pushClientTraffics receives a master panel's aggregated per-client usage
|
|
// (see InboundService.AcceptGlobalTraffic for the storage semantics).
|
|
func (a *InboundController) pushClientTraffics(c *gin.Context) {
|
|
var req struct {
|
|
MasterGuid string `json:"masterGuid"`
|
|
Traffics []*xray.ClientTraffic `json:"traffics"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, "success", 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
|
|
// Node IDs are panel-local and not portable across panels. Drop a node
|
|
// reference that is zero or that points to a node which doesn't exist on
|
|
// this panel, so a cross-panel export imports as a local inbound instead of
|
|
// failing with "record not found" when nodePushPlan looks the node up.
|
|
if inbound.NodeID != nil {
|
|
if *inbound.NodeID == 0 {
|
|
inbound.NodeID = nil
|
|
} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
|
|
inbound.NodeID = nil
|
|
}
|
|
}
|
|
|
|
for index := range inbound.ClientStats {
|
|
inbound.ClientStats[index].Id = 0
|
|
inbound.ClientStats[index].Enable = true
|
|
}
|
|
|
|
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)
|
|
notifyClientsChanged()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// getFallbacks returns the fallback rules attached to the master inbound.
|
|
func (a *InboundController) getFallbacks(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
rows, err := a.fallbackService.GetByMaster(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
jsonObj(c, rows, nil)
|
|
}
|
|
|
|
// setFallbacks atomically replaces the master inbound's fallback list
|
|
// and triggers an Xray restart so the new settings.fallbacks take effect.
|
|
func (a *InboundController) setFallbacks(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
type body struct {
|
|
Fallbacks []service.FallbackInput `json:"fallbacks"`
|
|
}
|
|
var b body
|
|
if err := c.ShouldBindJSON(&b); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
a.xrayService.SetToNeedRestart()
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
|
}
|