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'));
-
+
ID
@@ -913,7 +1011,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('delete') }}
+
+
+
+
+ {{ 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",