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",
Socks: "socks",
HTTP: "http",
Loopback: "loopback",
};
export const SSMethods = {
@ -1586,6 +1587,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.HTTP: return new Outbound.HttpSettings();
case Protocols.Wireguard: return new Outbound.WireguardSettings();
case Protocols.Hysteria: return new Outbound.HysteriaSettings();
case Protocols.Loopback: return new Outbound.LoopbackSettings();
default: return null;
}
}
@ -1603,6 +1605,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json);
case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json);
case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json);
case Protocols.Loopback: return Outbound.LoopbackSettings.fromJson(json);
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 {
constructor(action = 'direct', qtype = '', domain = '') {
super();

View file

@ -34,6 +34,7 @@ const props = defineProps({
open: { type: Boolean, default: false },
outbound: { type: Object, default: null },
existingTags: { type: Array, default: () => [] },
inboundTags: { type: Array, default: () => [] },
});
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 isWireguard = computed(() => proto.value === Protocols.Wireguard);
const isHysteria = computed(() => proto.value === Protocols.Hysteria);
const isLoopback = computed(() => proto.value === Protocols.Loopback);
function regenerateWgKeys() {
if (!outbound.value?.settings) return;
@ -311,6 +313,16 @@ function regenerateWgKeys() {
</a-form-item>
</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 ============== -->
<template v-if="isDNS">
<a-form-item :label="t('pages.inbounds.network')">

View file

@ -35,9 +35,19 @@ const props = defineProps({
templateSettings: { type: Object, default: null },
outboundsTraffic: { type: Array, default: () => [] },
outboundTestStates: { type: Object, default: () => ({}) },
inboundTags: { type: Array, default: () => [] },
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']);
// === Modal state ====================================================
@ -129,7 +139,9 @@ function outboundAddresses(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) {
return !!props.outboundTestStates?.[idx]?.testing;
@ -377,6 +389,7 @@ const rows = computed(() => {
v-model:open="modalOpen"
:outbound="editingOutbound"
:existing-tags="existingTags"
:inbound-tags="inboundTagOptions"
@confirm="onConfirm"
/>
</a-space>

View file

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