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:
MHSanaei
2026-05-17 07:44:01 +02:00
parent 2bcf287cf1
commit 62fd9f9d82
10 changed files with 380 additions and 28 deletions
+41 -2
View File
@@ -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.
+122
View File
@@ -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
}
+3 -2
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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",