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
+9 -6
View File
@@ -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);
+21
View File
@@ -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}',
},
],
},
@@ -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'));
<a-input v-model:value="firstClient.email" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.VMESS || protocol === Protocols.VLESS">
<a-form-item v-if="protocol === Protocols.VMESS || isVlessLike">
<template #label>
<a-tooltip title="Reset to a fresh UUID">
ID
@@ -913,7 +1011,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</template>
<!-- VLess decryption / encryption -->
<a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ sm: { span: 8 } }"
<a-form v-if="isVlessLike" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form-item label="Decryption">
<a-input v-model:value="inbound.settings.decryption" />
@@ -937,6 +1035,42 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</a-form-item>
</a-form>
<a-card v-if="protocol === Protocols.PORTFALLBACK" size="small" class="mt-12"
:title="t('pages.inbounds.portFallback.title') || 'Fallback children'">
<a-typography-paragraph type="secondary">
{{ 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.' }}
</a-typography-paragraph>
<a-table :columns="fallbackChildColumns" :data-source="fallbackChildren" row-key="rowKey"
size="small" :pagination="false">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'childId'">
<a-select v-model:value="record.childId" :options="fallbackChildOptions" :show-search="true"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())"
style="width: 100%" />
</template>
<template v-else-if="column.key === 'name'">
<a-input v-model:value="record.name" placeholder="SNI" />
</template>
<template v-else-if="column.key === 'alpn'">
<a-input v-model:value="record.alpn" placeholder="h2 / http/1.1" />
</template>
<template v-else-if="column.key === 'path'">
<a-input v-model:value="record.path" placeholder="/path" />
</template>
<template v-else-if="column.key === 'xver'">
<a-input-number v-model:value="record.xver" :min="0" :max="2" style="width: 80px" />
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" danger @click="removeFallbackChild(index)">{{ t('delete') }}</a-button>
</template>
</template>
</a-table>
<a-button size="small" style="margin-top: 8px" @click="addFallbackChild">
<PlusOutlined /> {{ t('add') }}
</a-button>
</a-card>
<!-- Shadowsocks shared fields (method/network/ivCheck) -->
<a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
+2 -1
View File
@@ -661,7 +661,8 @@ function onRowAction({ key, dbInbound }) {
</a-layout-content>
</a-layout>
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound"
:db-inbounds="dbInbounds" @saved="refresh" />
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />