Преглед изворни кода

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 пре 1 дан
родитељ
комит
60e2af088d

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

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

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

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

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

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

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

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