feat(xray): add loopback outbound protocol

#4185
Surface xray-core's loopback outbound in the Outbounds form so users
can re-route already-processed traffic back into a named inbound for
secondary routing (e.g. splitting TCP/UDP from one ingress). The
inboundTag field is an autocomplete over existing inbound tags, with
free-text fallback for inbounds defined outside the panel. Loopback
outbounds are excluded from the connectivity test since they have no
network endpoint.
This commit is contained in:
MHSanaei 2026-05-09 22:49:49 +02:00
parent 917f9b307e
commit 60e2af088d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 47 additions and 1 deletions

View file

@ -12,6 +12,7 @@ export const Protocols = {
Hysteria: "hysteria", Hysteria: "hysteria",
Socks: "socks", Socks: "socks",
HTTP: "http", HTTP: "http",
Loopback: "loopback",
}; };
export const SSMethods = { export const SSMethods = {
@ -1586,6 +1587,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.HTTP: return new Outbound.HttpSettings(); case Protocols.HTTP: return new Outbound.HttpSettings();
case Protocols.Wireguard: return new Outbound.WireguardSettings(); case Protocols.Wireguard: return new Outbound.WireguardSettings();
case Protocols.Hysteria: return new Outbound.HysteriaSettings(); case Protocols.Hysteria: return new Outbound.HysteriaSettings();
case Protocols.Loopback: return new Outbound.LoopbackSettings();
default: return null; default: return null;
} }
} }
@ -1603,6 +1605,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json); case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json);
case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json); case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json);
case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json); case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json);
case Protocols.Loopback: return Outbound.LoopbackSettings.fromJson(json);
default: return null; default: return null;
} }
} }
@ -1782,6 +1785,23 @@ Outbound.BlackholeSettings = class extends CommonClass {
} }
}; };
Outbound.LoopbackSettings = class extends CommonClass {
constructor(inboundTag = '') {
super();
this.inboundTag = inboundTag;
}
static fromJson(json = {}) {
return new Outbound.LoopbackSettings(json.inboundTag || '');
}
toJson() {
return {
inboundTag: this.inboundTag || undefined,
};
}
};
Outbound.DNSRule = class extends CommonClass { Outbound.DNSRule = class extends CommonClass {
constructor(action = 'direct', qtype = '', domain = '') { constructor(action = 'direct', qtype = '', domain = '') {
super(); super();

View file

@ -34,6 +34,7 @@ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
outbound: { type: Object, default: null }, outbound: { type: Object, default: null },
existingTags: { type: Array, default: () => [] }, existingTags: { type: Array, default: () => [] },
inboundTags: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:open', 'confirm']); const emit = defineEmits(['update:open', 'confirm']);
@ -199,6 +200,7 @@ const isBlackhole = computed(() => proto.value === Protocols.Blackhole);
const isDNS = computed(() => proto.value === Protocols.DNS); const isDNS = computed(() => proto.value === Protocols.DNS);
const isWireguard = computed(() => proto.value === Protocols.Wireguard); const isWireguard = computed(() => proto.value === Protocols.Wireguard);
const isHysteria = computed(() => proto.value === Protocols.Hysteria); const isHysteria = computed(() => proto.value === Protocols.Hysteria);
const isLoopback = computed(() => proto.value === Protocols.Loopback);
function regenerateWgKeys() { function regenerateWgKeys() {
if (!outbound.value?.settings) return; if (!outbound.value?.settings) return;
@ -311,6 +313,16 @@ function regenerateWgKeys() {
</a-form-item> </a-form-item>
</template> </template>
<!-- ============== Loopback ============== -->
<template v-if="isLoopback">
<a-form-item label="Inbound tag">
<a-auto-complete v-model:value="outbound.settings.inboundTag"
:options="inboundTags.map((tag) => ({ value: tag }))"
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
placeholder="tag of an existing inbound to re-route into" />
</a-form-item>
</template>
<!-- ============== DNS ============== --> <!-- ============== DNS ============== -->
<template v-if="isDNS"> <template v-if="isDNS">
<a-form-item :label="t('pages.inbounds.network')"> <a-form-item :label="t('pages.inbounds.network')">

View file

@ -35,9 +35,19 @@ const props = defineProps({
templateSettings: { type: Object, default: null }, templateSettings: { type: Object, default: null },
outboundsTraffic: { type: Array, default: () => [] }, outboundsTraffic: { type: Array, default: () => [] },
outboundTestStates: { type: Object, default: () => ({}) }, outboundTestStates: { type: Object, default: () => ({}) },
inboundTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
}); });
const inboundTagOptions = computed(() => {
const out = new Set();
for (const ib of props.templateSettings?.inbounds || []) {
if (ib.tag) out.add(ib.tag);
}
for (const t of props.inboundTags || []) out.add(t);
return [...out];
});
const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord']); const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord']);
// === Modal state ==================================================== // === Modal state ====================================================
@ -129,7 +139,9 @@ function outboundAddresses(o) {
} }
function isUntestable(o) { function isUntestable(o) {
return o.protocol === 'blackhole' || o.tag === 'blocked'; return o.protocol === Protocols.Blackhole
|| o.protocol === Protocols.Loopback
|| o.tag === 'blocked';
} }
function isTesting(idx) { function isTesting(idx) {
return !!props.outboundTestStates?.[idx]?.testing; return !!props.outboundTestStates?.[idx]?.testing;
@ -377,6 +389,7 @@ const rows = computed(() => {
v-model:open="modalOpen" v-model:open="modalOpen"
:outbound="editingOutbound" :outbound="editingOutbound"
:existing-tags="existingTags" :existing-tags="existingTags"
:inbound-tags="inboundTagOptions"
@confirm="onConfirm" @confirm="onConfirm"
/> />
</a-space> </a-space>

View file

@ -294,6 +294,7 @@ function confirmRestart() {
:template-settings="templateSettings" :template-settings="templateSettings"
:outbounds-traffic="outboundsTraffic" :outbounds-traffic="outboundsTraffic"
:outbound-test-states="outboundTestStates" :outbound-test-states="outboundTestStates"
:inbound-tags="inboundTags"
:is-mobile="isMobile" :is-mobile="isMobile"
@reset-traffic="resetOutboundsTraffic" @reset-traffic="resetOutboundsTraffic"
@test="onTestOutbound" @test="onTestOutbound"