Browse Source

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.
MHSanaei 19 giờ trước cách đây
mục cha
commit
60e2af088d

+ 20 - 0
frontend/src/models/outbound.js

@@ -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();

+ 12 - 0
frontend/src/pages/xray/OutboundFormModal.vue

@@ -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')">

+ 14 - 1
frontend/src/pages/xray/OutboundsTab.vue

@@ -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>

+ 1 - 0
frontend/src/pages/xray/XrayPage.vue

@@ -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"