mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +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:
parent
2bcf287cf1
commit
62fd9f9d82
10 changed files with 380 additions and 28 deletions
|
|
@ -14,16 +14,17 @@ type Protocol string
|
||||||
|
|
||||||
// Protocol constants for different Xray inbound protocols
|
// Protocol constants for different Xray inbound protocols
|
||||||
const (
|
const (
|
||||||
VMESS Protocol = "vmess"
|
VMESS Protocol = "vmess"
|
||||||
VLESS Protocol = "vless"
|
VLESS Protocol = "vless"
|
||||||
Tunnel Protocol = "tunnel"
|
Tunnel Protocol = "tunnel"
|
||||||
HTTP Protocol = "http"
|
HTTP Protocol = "http"
|
||||||
Trojan Protocol = "trojan"
|
Trojan Protocol = "trojan"
|
||||||
Shadowsocks Protocol = "shadowsocks"
|
Shadowsocks Protocol = "shadowsocks"
|
||||||
Mixed Protocol = "mixed"
|
Mixed Protocol = "mixed"
|
||||||
WireGuard Protocol = "wireguard"
|
WireGuard Protocol = "wireguard"
|
||||||
Hysteria Protocol = "hysteria"
|
Hysteria Protocol = "hysteria"
|
||||||
Hysteria2 Protocol = "hysteria2"
|
Hysteria2 Protocol = "hysteria2"
|
||||||
|
PortFallback Protocol = "portfallback"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
// 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.
|
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||||
listen := i.Listen
|
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 == "" {
|
if listen == "" {
|
||||||
listen = "0.0.0.0"
|
listen = "0.0.0.0"
|
||||||
}
|
}
|
||||||
listen = fmt.Sprintf("\"%v\"", listen)
|
listen = fmt.Sprintf("\"%v\"", listen)
|
||||||
|
protocol := string(i.Protocol)
|
||||||
|
if i.Protocol == PortFallback {
|
||||||
|
protocol = string(VLESS)
|
||||||
|
}
|
||||||
return &xray.InboundConfig{
|
return &xray.InboundConfig{
|
||||||
Listen: json_util.RawMessage(listen),
|
Listen: json_util.RawMessage(listen),
|
||||||
Port: i.Port,
|
Port: i.Port,
|
||||||
Protocol: string(i.Protocol),
|
Protocol: protocol,
|
||||||
Settings: json_util.RawMessage(i.Settings),
|
Settings: json_util.RawMessage(i.Settings),
|
||||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||||
Tag: i.Tag,
|
Tag: i.Tag,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const Protocols = {
|
||||||
HTTP: 'http',
|
HTTP: 'http',
|
||||||
TUNNEL: 'tunnel',
|
TUNNEL: 'tunnel',
|
||||||
TUN: 'tun',
|
TUN: 'tun',
|
||||||
|
PORTFALLBACK: 'portfallback',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SSMethods = {
|
export const SSMethods = {
|
||||||
|
|
@ -1842,14 +1843,14 @@ export class Inbound extends XrayCommonClass {
|
||||||
|
|
||||||
canEnableTls() {
|
canEnableTls() {
|
||||||
if (this.protocol === Protocols.HYSTERIA) return true;
|
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);
|
return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.network);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this is used for xtls-rprx-vision
|
//this is used for xtls-rprx-vision
|
||||||
canEnableTlsFlow() {
|
canEnableTlsFlow() {
|
||||||
if (((this.stream.security === 'tls') || (this.stream.security === 'reality')) && (this.network === "tcp")) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1864,12 +1865,12 @@ export class Inbound extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
canEnableReality() {
|
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);
|
return ["tcp", "http", "grpc", "xhttp"].includes(this.network);
|
||||||
}
|
}
|
||||||
|
|
||||||
canEnableStream() {
|
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() {
|
reset() {
|
||||||
|
|
@ -2443,7 +2444,8 @@ Inbound.Settings = class extends XrayCommonClass {
|
||||||
static getSettings(protocol) {
|
static getSettings(protocol) {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case Protocols.VMESS: return new Inbound.VmessSettings(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.TROJAN: return new Inbound.TrojanSettings(protocol);
|
||||||
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings(protocol);
|
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings(protocol);
|
||||||
case Protocols.TUNNEL: return new Inbound.TunnelSettings(protocol);
|
case Protocols.TUNNEL: return new Inbound.TunnelSettings(protocol);
|
||||||
|
|
@ -2459,7 +2461,8 @@ Inbound.Settings = class extends XrayCommonClass {
|
||||||
static fromJson(protocol, json) {
|
static fromJson(protocol, json) {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case Protocols.VMESS: return Inbound.VmessSettings.fromJson(json);
|
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.TROJAN: return Inbound.TrojanSettings.fromJson(json);
|
||||||
case Protocols.SHADOWSOCKS: return Inbound.ShadowsocksSettings.fromJson(json);
|
case Protocols.SHADOWSOCKS: return Inbound.ShadowsocksSettings.fromJson(json);
|
||||||
case Protocols.TUNNEL: return Inbound.TunnelSettings.fromJson(json);
|
case Protocols.TUNNEL: return Inbound.TunnelSettings.fromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,27 @@ export const sections = [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
{ 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 },
|
open: { type: Boolean, default: false },
|
||||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||||
dbInbound: { type: Object, default: null },
|
dbInbound: { type: Object, default: null },
|
||||||
|
dbInbounds: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'saved']);
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
@ -127,6 +128,7 @@ const isMultiUser = computed(() => {
|
||||||
switch (inbound.value.protocol) {
|
switch (inbound.value.protocol) {
|
||||||
case Protocols.VMESS:
|
case Protocols.VMESS:
|
||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
|
case Protocols.PORTFALLBACK:
|
||||||
case Protocols.TROJAN:
|
case Protocols.TROJAN:
|
||||||
case Protocols.HYSTERIA:
|
case Protocols.HYSTERIA:
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -141,7 +143,8 @@ const clientsArray = computed(() => {
|
||||||
if (!inbound.value) return [];
|
if (!inbound.value) return [];
|
||||||
switch (inbound.value.protocol) {
|
switch (inbound.value.protocol) {
|
||||||
case Protocols.VMESS: return inbound.value.settings.vmesses || [];
|
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.TROJAN: return inbound.value.settings.trojans || [];
|
||||||
case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || [];
|
case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || [];
|
||||||
case Protocols.HYSTERIA: return inbound.value.settings.hysterias || [];
|
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 firstClient = computed(() => clientsArray.value[0] || null);
|
||||||
const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true);
|
const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true);
|
||||||
const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
|
const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
|
||||||
|
|
@ -233,10 +317,16 @@ watch(() => props.open, (next) => {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
if (props.mode === 'edit' && props.dbInbound) {
|
if (props.mode === 'edit' && props.dbInbound) {
|
||||||
loadFromDbInbound(props.dbInbound);
|
loadFromDbInbound(props.dbInbound);
|
||||||
|
if (props.dbInbound.protocol === Protocols.PORTFALLBACK) {
|
||||||
|
loadFallbackChildren(props.dbInbound.id);
|
||||||
|
} else {
|
||||||
|
fallbackChildren.value = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
inbound.value = makeFreshInbound(Protocols.VLESS);
|
inbound.value = makeFreshInbound(Protocols.VLESS);
|
||||||
dbForm.value = freshDbForm();
|
dbForm.value = freshDbForm();
|
||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
|
fallbackChildren.value = [];
|
||||||
}
|
}
|
||||||
activeTabKey.value = 'basic';
|
activeTabKey.value = 'basic';
|
||||||
advancedSectionKey.value = 'all';
|
advancedSectionKey.value = 'all';
|
||||||
|
|
@ -711,6 +801,14 @@ async function submit() {
|
||||||
: '/panel/api/inbounds/add';
|
: '/panel/api/inbounds/add';
|
||||||
const msg = await HttpUtil.post(url, payload);
|
const msg = await HttpUtil.post(url, payload);
|
||||||
if (msg?.success) {
|
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');
|
emit('saved');
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
@ -819,7 +917,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
||||||
<a-input v-model:value="firstClient.email" />
|
<a-input v-model:value="firstClient.email" />
|
||||||
</a-form-item>
|
</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>
|
<template #label>
|
||||||
<a-tooltip title="Reset to a fresh UUID">
|
<a-tooltip title="Reset to a fresh UUID">
|
||||||
ID
|
ID
|
||||||
|
|
@ -913,7 +1011,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- VLess decryption / encryption -->
|
<!-- 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">
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||||
<a-form-item label="Decryption">
|
<a-form-item label="Decryption">
|
||||||
<a-input v-model:value="inbound.settings.decryption" />
|
<a-input v-model:value="inbound.settings.decryption" />
|
||||||
|
|
@ -937,6 +1035,42 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</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) -->
|
<!-- Shadowsocks shared fields (method/network/ivCheck) -->
|
||||||
<a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ sm: { span: 8 } }"
|
<a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||||
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||||
|
|
|
||||||
|
|
@ -661,7 +661,8 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</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"
|
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
||||||
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
||||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ import (
|
||||||
|
|
||||||
// InboundController handles HTTP requests related to Xray inbounds management.
|
// InboundController handles HTTP requests related to Xray inbounds management.
|
||||||
type InboundController struct {
|
type InboundController struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
|
fallbackService service.FallbackService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInboundController creates a new InboundController and sets up its routes.
|
// 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("/lastOnline", a.lastOnline)
|
||||||
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
||||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||||
|
g.GET("/:id/fallbackChildren", a.getFallbackChildren)
|
||||||
|
g.POST("/:id/fallbackChildren", a.setFallbackChildren)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CopyInboundClientsRequest struct {
|
type CopyInboundClientsRequest struct {
|
||||||
|
|
@ -632,6 +635,42 @@ func (a *InboundController) getSubLinks(c *gin.Context) {
|
||||||
jsonObj(c, links, nil)
|
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
|
// 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
|
// 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.
|
// protocol has no URL form, or when the email isn't found on the inbound.
|
||||||
|
|
|
||||||
122
web/service/fallback.go
Normal file
122
web/service/fallback.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -24,8 +24,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type InboundService struct {
|
type InboundService struct {
|
||||||
xrayApi xray.XrayAPI
|
xrayApi xray.XrayAPI
|
||||||
clientService ClientService
|
clientService ClientService
|
||||||
|
fallbackService FallbackService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
|
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
|
@ -166,8 +167,29 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
finalClients = append(finalClients, entry)
|
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
|
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, "", " ")
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,12 @@
|
||||||
"node": "Node",
|
"node": "Node",
|
||||||
"deployTo": "Deploy to",
|
"deployTo": "Deploy to",
|
||||||
"localPanel": "Local panel",
|
"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",
|
"protocol": "Protocol",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"portMap": "Port Mapping",
|
"portMap": "Port Mapping",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue