mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
feat(inbounds): add Port-with-Fallback inbound type
Adds a new "portfallback" protocol that emits as a VLESS-TLS inbound under the hood but is paired with a sidecar table of child inbounds. Panel auto-builds settings.fallbacks at Xray-config-gen time from the sidecar — each child's listen+port becomes the fallback dest, with SNI/ALPN/path/xver match criteria pulled from the row. No more typing loopback ports by hand or keeping settings.fallbacks in sync. Backend: new FallbackService (Get/SetChildren, BuildFallbacksJSON); two new routes (GET/POST /panel/api/inbounds/:id/fallbackChildren); xray.GetXrayConfig injects fallbacks for PortFallback inbounds; the inbound model emits protocol="vless" so Xray accepts the config. Frontend: PORTFALLBACK joins the protocol dropdown; selecting it shows the standard VLESS controls plus a Fallback Children table (inbound picker + per-row SNI/ALPN/path/xver). Children are loaded on edit and replaced atomically on save. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,9 @@ import (
|
||||
|
||||
// InboundController handles HTTP requests related to Xray inbounds management.
|
||||
type InboundController struct {
|
||||
inboundService service.InboundService
|
||||
xrayService service.XrayService
|
||||
inboundService service.InboundService
|
||||
xrayService service.XrayService
|
||||
fallbackService service.FallbackService
|
||||
}
|
||||
|
||||
// NewInboundController creates a new InboundController and sets up its routes.
|
||||
@@ -87,6 +88,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||
g.GET("/:id/fallbackChildren", a.getFallbackChildren)
|
||||
g.POST("/:id/fallbackChildren", a.setFallbackChildren)
|
||||
}
|
||||
|
||||
type CopyInboundClientsRequest struct {
|
||||
@@ -632,6 +635,42 @@ func (a *InboundController) getSubLinks(c *gin.Context) {
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// getClientLinks returns the URL(s) for one client on one inbound — the same
|
||||
// string the Copy URL button copies in the panel UI. Empty array when the
|
||||
// protocol has no URL form, or when the email isn't found on the inbound.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FallbackService struct{}
|
||||
|
||||
type FallbackChildInput struct {
|
||||
ChildId int `json:"childId"`
|
||||
Name string `json:"name"`
|
||||
Alpn string `json:"alpn"`
|
||||
Path string `json:"path"`
|
||||
Xver int `json:"xver"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (s *FallbackService) GetChildren(masterId int) ([]model.InboundFallbackChild, error) {
|
||||
var rows []model.InboundFallbackChild
|
||||
err := database.GetDB().
|
||||
Where("master_id = ?", masterId).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *FallbackService) SetChildren(masterId int, children []FallbackChildInput) error {
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallbackChild{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i, c := range children {
|
||||
if c.ChildId <= 0 || c.ChildId == masterId {
|
||||
continue
|
||||
}
|
||||
row := model.InboundFallbackChild{
|
||||
MasterId: masterId,
|
||||
ChildId: c.ChildId,
|
||||
Name: c.Name,
|
||||
Alpn: c.Alpn,
|
||||
Path: c.Path,
|
||||
Xver: c.Xver,
|
||||
SortOrder: c.SortOrder,
|
||||
}
|
||||
if row.SortOrder == 0 {
|
||||
row.SortOrder = i
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
var rows []model.InboundFallbackChild
|
||||
err := tx.Where("master_id = ?", masterId).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
childIds := make([]int, 0, len(rows))
|
||||
for i := range rows {
|
||||
childIds = append(childIds, rows[i].ChildId)
|
||||
}
|
||||
var children []model.Inbound
|
||||
if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byId := make(map[int]*model.Inbound, len(children))
|
||||
for i := range children {
|
||||
byId[children[i].Id] = &children[i]
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
child, ok := byId[r.ChildId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
listen := strings.TrimSpace(child.Listen)
|
||||
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
|
||||
listen = "127.0.0.1"
|
||||
}
|
||||
entry := map[string]any{
|
||||
"dest": fmt.Sprintf("%s:%d", listen, child.Port),
|
||||
}
|
||||
if r.Name != "" {
|
||||
entry["name"] = r.Name
|
||||
}
|
||||
if r.Alpn != "" {
|
||||
entry["alpn"] = r.Alpn
|
||||
}
|
||||
if r.Path != "" {
|
||||
entry["path"] = r.Path
|
||||
}
|
||||
if r.Xver > 0 {
|
||||
entry["xver"] = r.Xver
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -24,8 +24,9 @@ import (
|
||||
)
|
||||
|
||||
type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
clientService ClientService
|
||||
xrayApi xray.XrayAPI
|
||||
clientService ClientService
|
||||
fallbackService FallbackService
|
||||
}
|
||||
|
||||
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
|
||||
|
||||
+23
-1
@@ -6,6 +6,7 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
@@ -166,8 +167,29 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
finalClients = append(finalClients, entry)
|
||||
}
|
||||
|
||||
if _, hadClients := settings["clients"]; hadClients || len(finalClients) > 0 {
|
||||
_, hadClients := settings["clients"]
|
||||
mutated := hadClients || len(finalClients) > 0
|
||||
if mutated {
|
||||
settings["clients"] = finalClients
|
||||
}
|
||||
|
||||
if inbound.Protocol == model.PortFallback {
|
||||
fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
if _, ok := settings["decryption"]; !ok {
|
||||
settings["decryption"] = "none"
|
||||
}
|
||||
mutated = true
|
||||
}
|
||||
|
||||
if mutated {
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -250,6 +250,12 @@
|
||||
"node": "Node",
|
||||
"deployTo": "Deploy to",
|
||||
"localPanel": "Local panel",
|
||||
"portFallback": {
|
||||
"title": "Fallback children",
|
||||
"help": "Pick inbounds that should catch traffic this VLESS-TLS inbound does not match. Each child must listen on 127.0.0.1 to receive forwarded connections.",
|
||||
"child": "Inbound",
|
||||
"path": "Path"
|
||||
},
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"portMap": "Port Mapping",
|
||||
|
||||
Reference in New Issue
Block a user