From 62fd9f9d82af711a156033c2c8ebd8a71988fe5e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 17 May 2026 07:44:01 +0200 Subject: [PATCH] feat(inbounds): add Port-with-Fallback inbound type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- database/model/model.go | 29 ++-- frontend/src/models/inbound.js | 15 +- frontend/src/pages/api-docs/endpoints.js | 21 +++ .../src/pages/inbounds/InboundFormModal.vue | 140 +++++++++++++++++- frontend/src/pages/inbounds/InboundsPage.vue | 3 +- web/controller/inbound.go | 43 +++++- web/service/fallback.go | 122 +++++++++++++++ web/service/inbound.go | 5 +- web/service/xray.go | 24 ++- web/translation/en-US.json | 6 + 10 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 web/service/fallback.go diff --git a/database/model/model.go b/database/model/model.go index 2a15e22d..bc023508 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -14,16 +14,17 @@ type Protocol string // Protocol constants for different Xray inbound protocols const ( - VMESS Protocol = "vmess" - VLESS Protocol = "vless" - Tunnel Protocol = "tunnel" - HTTP Protocol = "http" - Trojan Protocol = "trojan" - Shadowsocks Protocol = "shadowsocks" - Mixed Protocol = "mixed" - WireGuard Protocol = "wireguard" - Hysteria Protocol = "hysteria" - Hysteria2 Protocol = "hysteria2" + VMESS Protocol = "vmess" + VLESS Protocol = "vless" + Tunnel Protocol = "tunnel" + HTTP Protocol = "http" + Trojan Protocol = "trojan" + Shadowsocks Protocol = "shadowsocks" + Mixed Protocol = "mixed" + WireGuard Protocol = "wireguard" + Hysteria Protocol = "hysteria" + Hysteria2 Protocol = "hysteria2" + PortFallback Protocol = "portfallback" ) // IsHysteria returns true for both "hysteria" and "hysteria2". @@ -100,16 +101,18 @@ type ApiToken struct { // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { listen := i.Listen - // Default to 0.0.0.0 (all interfaces) when listen is empty - // This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0 if listen == "" { listen = "0.0.0.0" } listen = fmt.Sprintf("\"%v\"", listen) + protocol := string(i.Protocol) + if i.Protocol == PortFallback { + protocol = string(VLESS) + } return &xray.InboundConfig{ Listen: json_util.RawMessage(listen), Port: i.Port, - Protocol: string(i.Protocol), + Protocol: protocol, Settings: json_util.RawMessage(i.Settings), StreamSettings: json_util.RawMessage(i.StreamSettings), Tag: i.Tag, diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index 830dc9a9..5f7cc561 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -13,6 +13,7 @@ export const Protocols = { HTTP: 'http', TUNNEL: 'tunnel', TUN: 'tun', + PORTFALLBACK: 'portfallback', }; export const SSMethods = { @@ -1842,14 +1843,14 @@ export class Inbound extends XrayCommonClass { canEnableTls() { if (this.protocol === Protocols.HYSTERIA) return true; - if (![Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(this.protocol)) return false; + if (![Protocols.VMESS, Protocols.VLESS, Protocols.PORTFALLBACK, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(this.protocol)) return false; return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.network); } //this is used for xtls-rprx-vision canEnableTlsFlow() { if (((this.stream.security === 'tls') || (this.stream.security === 'reality')) && (this.network === "tcp")) { - return this.protocol === Protocols.VLESS; + return this.protocol === Protocols.VLESS || this.protocol === Protocols.PORTFALLBACK; } return false; } @@ -1864,12 +1865,12 @@ export class Inbound extends XrayCommonClass { } canEnableReality() { - if (![Protocols.VLESS, Protocols.TROJAN].includes(this.protocol)) return false; + if (![Protocols.VLESS, Protocols.PORTFALLBACK, Protocols.TROJAN].includes(this.protocol)) return false; return ["tcp", "http", "grpc", "xhttp"].includes(this.network); } canEnableStream() { - return [Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA].includes(this.protocol); + return [Protocols.VMESS, Protocols.VLESS, Protocols.PORTFALLBACK, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA].includes(this.protocol); } reset() { @@ -2443,7 +2444,8 @@ Inbound.Settings = class extends XrayCommonClass { static getSettings(protocol) { switch (protocol) { case Protocols.VMESS: return new Inbound.VmessSettings(protocol); - case Protocols.VLESS: return new Inbound.VLESSSettings(protocol); + case Protocols.VLESS: + case Protocols.PORTFALLBACK: return new Inbound.VLESSSettings(protocol); case Protocols.TROJAN: return new Inbound.TrojanSettings(protocol); case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings(protocol); case Protocols.TUNNEL: return new Inbound.TunnelSettings(protocol); @@ -2459,7 +2461,8 @@ Inbound.Settings = class extends XrayCommonClass { static fromJson(protocol, json) { switch (protocol) { case Protocols.VMESS: return Inbound.VmessSettings.fromJson(json); - case Protocols.VLESS: return Inbound.VLESSSettings.fromJson(json); + case Protocols.VLESS: + case Protocols.PORTFALLBACK: return Inbound.VLESSSettings.fromJson(json); case Protocols.TROJAN: return Inbound.TrojanSettings.fromJson(json); case Protocols.SHADOWSOCKS: return Inbound.ShadowsocksSettings.fromJson(json); case Protocols.TUNNEL: return Inbound.TunnelSettings.fromJson(json); diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 2b753fcc..090246ec 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -292,6 +292,27 @@ export const sections = [ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' }, ], }, + { + method: 'GET', + path: '/panel/api/inbounds/:id/fallbackChildren', + summary: 'List fallback child inbounds for a Port-with-Fallback master inbound. Each row links a master inbound to one child inbound plus optional SNI/ALPN/path/xver match criteria.', + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' }, + ], + response: + '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "masterId": 10,\n "childId": 11,\n "name": "trojan.example.com",\n "alpn": "",\n "path": "",\n "xver": 0,\n "sortOrder": 0\n }\n ]\n}', + }, + { + method: 'POST', + path: '/panel/api/inbounds/:id/fallbackChildren', + summary: 'Replace the entire fallback-children set for a master inbound. Body is JSON. Triggers an Xray restart.', + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' }, + { name: 'children', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' }, + ], + body: '{\n "children": [\n { "childId": 11, "name": "trojan.example.com", "xver": 0 },\n { "childId": 12, "alpn": "h2", "sortOrder": 1 }\n ]\n}', + response: '{\n "success": true,\n "msg": "Inbound updated"\n}', + }, ], }, diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 8d2019de..236d2266 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -56,6 +56,7 @@ const props = defineProps({ open: { type: Boolean, default: false }, mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) }, dbInbound: { type: Object, default: null }, + dbInbounds: { type: Array, default: () => [] }, }); const emit = defineEmits(['update:open', 'saved']); @@ -127,6 +128,7 @@ const isMultiUser = computed(() => { switch (inbound.value.protocol) { case Protocols.VMESS: case Protocols.VLESS: + case Protocols.PORTFALLBACK: case Protocols.TROJAN: case Protocols.HYSTERIA: return true; @@ -141,7 +143,8 @@ const clientsArray = computed(() => { if (!inbound.value) return []; switch (inbound.value.protocol) { case Protocols.VMESS: return inbound.value.settings.vmesses || []; - case Protocols.VLESS: return inbound.value.settings.vlesses || []; + case Protocols.VLESS: + case Protocols.PORTFALLBACK: return inbound.value.settings.vlesses || []; case Protocols.TROJAN: return inbound.value.settings.trojans || []; case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || []; case Protocols.HYSTERIA: return inbound.value.settings.hysterias || []; @@ -149,6 +152,87 @@ const clientsArray = computed(() => { } }); +const isVlessLike = computed(() => { + if (!inbound.value) return false; + return inbound.value.protocol === Protocols.VLESS + || inbound.value.protocol === Protocols.PORTFALLBACK; +}); + +const fallbackChildren = ref([]); +let fallbackChildRowKey = 0; + +const fallbackChildColumns = computed(() => [ + { title: t('pages.inbounds.portFallback.child') || 'Inbound', key: 'childId', width: '40%' }, + { title: 'SNI', key: 'name' }, + { title: 'ALPN', key: 'alpn' }, + { title: t('pages.inbounds.portFallback.path') || 'Path', key: 'path' }, + { title: 'xver', key: 'xver', width: 100 }, + { title: '', key: 'actions', width: 90 }, +]); + +const fallbackChildOptions = computed(() => { + const list = props.dbInbounds || []; + const masterId = props.dbInbound?.id; + return list + .filter((ib) => ib.id !== masterId && ib.protocol !== Protocols.PORTFALLBACK) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + })); +}); + +function addFallbackChild() { + fallbackChildren.value.push({ + rowKey: `row-${++fallbackChildRowKey}`, + childId: null, + name: '', + alpn: '', + path: '', + xver: 0, + }); +} + +function removeFallbackChild(idx) { + fallbackChildren.value.splice(idx, 1); +} + +async function loadFallbackChildren(masterId) { + fallbackChildren.value = []; + if (!masterId) return; + const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbackChildren`); + if (!msg?.success || !Array.isArray(msg.obj)) return; + fallbackChildren.value = msg.obj.map((r) => ({ + rowKey: `row-${++fallbackChildRowKey}`, + childId: r.childId, + name: r.name || '', + alpn: r.alpn || '', + path: r.path || '', + xver: r.xver || 0, + })); +} + +async function saveFallbackChildren(masterId) { + if (!masterId) return true; + const payload = { + children: fallbackChildren.value + .filter((c) => c.childId) + .map((c, i) => ({ + childId: c.childId, + name: c.name, + alpn: c.alpn, + path: c.path, + xver: Number(c.xver) || 0, + sortOrder: i, + })), + }; + const msg = await HttpUtil.post( + `/panel/api/inbounds/${masterId}/fallbackChildren`, + payload, + { headers: { 'Content-Type': 'application/json' } }, + ); + return !!msg?.success; +} + const firstClient = computed(() => clientsArray.value[0] || null); const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true); const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true); @@ -233,10 +317,16 @@ watch(() => props.open, (next) => { if (!next) return; if (props.mode === 'edit' && props.dbInbound) { loadFromDbInbound(props.dbInbound); + if (props.dbInbound.protocol === Protocols.PORTFALLBACK) { + loadFallbackChildren(props.dbInbound.id); + } else { + fallbackChildren.value = []; + } } else { inbound.value = makeFreshInbound(Protocols.VLESS); dbForm.value = freshDbForm(); primeAdvancedJson(); + fallbackChildren.value = []; } activeTabKey.value = 'basic'; advancedSectionKey.value = 'all'; @@ -711,6 +801,14 @@ async function submit() { : '/panel/api/inbounds/add'; const msg = await HttpUtil.post(url, payload); if (msg?.success) { + if (inbound.value.protocol === Protocols.PORTFALLBACK) { + const masterId = props.mode === 'edit' + ? props.dbInbound.id + : (msg.obj?.id || msg.obj?.Id); + if (masterId) { + await saveFallbackChildren(masterId); + } + } emit('saved'); close(); } @@ -819,7 +917,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - + - @@ -937,6 +1035,42 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); + + + {{ t('pages.inbounds.portFallback.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.' }} + + + + + + {{ t('add') }} + + + diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue index ed310672..778206fa 100644 --- a/frontend/src/pages/inbounds/InboundsPage.vue +++ b/frontend/src/pages/inbounds/InboundsPage.vue @@ -661,7 +661,8 @@ function onRowAction({ key, dbInbound }) { - + diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 541ae449..774973ec 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -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. diff --git a/web/service/fallback.go b/web/service/fallback.go new file mode 100644 index 00000000..9a414ee9 --- /dev/null +++ b/web/service/fallback.go @@ -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 +} diff --git a/web/service/inbound.go b/web/service/inbound.go index d23dbe86..78431c8c 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -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) { diff --git a/web/service/xray.go b/web/service/xray.go index 0e36f05e..6d9c3a70 100644 --- a/web/service/xray.go +++ b/web/service/xray.go @@ -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 diff --git a/web/translation/en-US.json b/web/translation/en-US.json index ee21ffe5..55c7e305 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -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",