Files
3x-ui/frontend/src/pages/xray/OutboundFormModal.vue
MHSanaei b078d57692 feat(frontend): add FinalMask UI (TCP/UDP masks + QUIC params) to inbound and outbound
Mirrors web/html/form/stream/stream_finalmask.html as a shared
FinalMaskForm component used by both modals — they share the same
StreamSettings shape (addTcpMask/addUdpMask/finalmask/enableQuicParams)
so a single template handles both. Surfaces:
- TCP masks for raw/tcp/httpupgrade/ws/grpc/xhttp networks: fragment,
  sudoku, and header-custom (with the 2D clients/servers groups, each
  row supporting array/str/hex/base64 packets and a randomize button
  for base64).
- UDP masks for hysteria protocol or kcp network: hysteria gets just
  salamander; kcp gets the full type list (mkcp variants, header-*,
  xdns/xicmp, header-custom with flat client/server lists, and noise).
  Switching to xdns shrinks the kcp MTU to 900 to match the legacy
  panel's behavior.
- QUIC Params for hysteria or xhttp: congestion (incl. brutal up/down
  fields), debug, UDP hop ports/interval, idle/keepalive timeouts,
  path-MTU discovery toggle, and the four receive-window tunables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:29:50 +02:00

788 lines
34 KiB
Vue

<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { Wireguard } from '@/utils';
import {
Outbound,
Protocols,
SSMethods,
TLS_FLOW_CONTROL,
UTLS_FINGERPRINT,
ALPN_OPTION,
SNIFFING_OPTION,
USERS_SECURITY,
OutboundDomainStrategies,
WireguardDomainStrategy,
Address_Port_Strategy,
MODE_OPTION,
DNSRuleActions,
} from '@/models/outbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue';
const { t } = useI18n();
// Structured outbound add/edit modal — mirrors the legacy
// web/html/form/outbound.html. Covers every protocol + transport
// combination the legacy panel exposes; the JSON tab still lets
// power-users hand-edit fields the structured form doesn't surface
// (reverse-sniffing, exotic outbound DNS rules, etc.).
const props = defineProps({
open: { type: Boolean, default: false },
outbound: { type: Object, default: null },
existingTags: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open', 'confirm']);
const PROTOCOL_OPTIONS = Object.values(Protocols);
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT);
const ALPN_OPTIONS = Object.values(ALPN_OPTION);
const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
const NETWORK_LABELS = {
tcp: 'TCP (RAW)',
kcp: 'mKCP',
ws: 'WebSocket',
grpc: 'gRPC',
httpupgrade: 'HTTPUpgrade',
xhttp: 'XHTTP',
};
// Reactive draft — Outbound instance built from the prop on open.
const outbound = ref(null);
const isEdit = ref(false);
const activeKey = ref('1');
const linkInput = ref('');
// Advanced JSON editor — kept in sync with the parsed Outbound on tab
// switch so users can copy/paste a full JSON config when the structured
// form doesn't reach a field.
const advancedJson = ref('');
watch(() => props.open, (next) => {
if (!next) return;
if (props.outbound) {
isEdit.value = true;
outbound.value = Outbound.fromJson(props.outbound);
} else {
isEdit.value = false;
outbound.value = new Outbound();
}
activeKey.value = '1';
linkInput.value = '';
primeAdvancedJson();
});
watch(activeKey, (key) => {
if (key === '2') primeAdvancedJson();
});
function primeAdvancedJson() {
if (!outbound.value) { advancedJson.value = ''; return; }
try {
advancedJson.value = JSON.stringify(outbound.value.toJson(), null, 2);
} catch (_e) {
advancedJson.value = '';
}
}
function close() { emit('update:open', false); }
function onProtocolChange(next) {
if (!outbound.value) return;
outbound.value.protocol = next;
}
function streamNetworkChange(next) {
if (!outbound.value?.stream) return;
outbound.value.stream.network = next;
if (!outbound.value.canEnableTls()) outbound.value.stream.security = 'none';
}
const duplicateTag = computed(() => {
if (!outbound.value?.tag) return false;
const myTag = outbound.value.tag.trim();
if (!myTag) return false;
if (isEdit.value && props.outbound?.tag === myTag) return false;
return (props.existingTags || []).includes(myTag);
});
// ============== Submit ==============
function onOk() {
if (!outbound.value) return;
if (!outbound.value.tag?.trim()) {
message.error(t('somethingWentWrong'));
return;
}
if (duplicateTag.value) {
message.error(t('somethingWentWrong'));
return;
}
// If user spent time in the JSON tab, prefer that body — round-trip
// it through Outbound.fromJson so the wire shape stays consistent.
if (activeKey.value === '2' && advancedJson.value.trim()) {
try {
const parsed = JSON.parse(advancedJson.value);
const built = Outbound.fromJson(parsed);
emit('confirm', built.toJson());
return;
} catch (e) {
message.error(`JSON: ${e.message}`);
return;
}
}
emit('confirm', outbound.value.toJson());
}
// ============== Link → outbound ==============
// The legacy "convert link" button takes a vmess://, vless://, ss://,
// trojan:// or hysteria2:// share-link string and rebuilds the
// outbound from it. The Outbound class doesn't have a native parser —
// we only support a friendly URL parse for the common shapes.
function convertLink() {
const link = linkInput.value.trim();
if (!link) return;
try {
if (link.startsWith('vmess://')) {
const data = JSON.parse(atob(link.replace(/^vmess:\/\//, '')));
const ob = new Outbound(data.ps || 'vmess', Protocols.VMess);
ob.settings.address = data.add;
ob.settings.port = Number(data.port) || 443;
ob.settings.id = data.id;
ob.settings.security = data.scy || USERS_SECURITY.AUTO;
ob.stream.network = data.net || 'tcp';
if (data.tls === 'tls') ob.stream.security = 'tls';
outbound.value = ob;
message.success(t('copySuccess'));
activeKey.value = '1';
} else {
message.warning('Only vmess:// links are supported by the quick converter for now — paste full JSON in the editor instead.');
}
} catch (e) {
message.error(`Link parse: ${e.message}`);
}
}
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
// Helper getters / shortcuts used by the template.
const proto = computed(() => outbound.value?.protocol);
const isVMess = computed(() => proto.value === Protocols.VMess);
const isVLESS = computed(() => proto.value === Protocols.VLESS);
const isVMessOrVLess = computed(() => isVMess.value || isVLESS.value);
const isTrojan = computed(() => proto.value === Protocols.Trojan);
const isShadowsocks = computed(() => proto.value === Protocols.Shadowsocks);
const isSocks = computed(() => proto.value === Protocols.Socks);
const isHTTP = computed(() => proto.value === Protocols.HTTP);
const isFreedom = computed(() => proto.value === Protocols.Freedom);
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);
function regenerateWgKeys() {
if (!outbound.value?.settings) return;
const pair = Wireguard.generateKeypair();
outbound.value.settings.secretKey = pair.privateKey;
outbound.value.settings.pubKey = pair.publicKey;
}
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="780px"
@ok="onOk" @cancel="close">
<a-tabs v-if="outbound" v-model:active-key="activeKey">
<!-- ============================== FORM ============================== -->
<a-tab-pane key="1" :tab="t('pages.xray.basicTemplate')">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<!-- Protocol -->
<a-form-item :label="t('protocol')">
<a-select :value="proto" @change="onProtocolChange">
<a-select-option v-for="p in PROTOCOL_OPTIONS" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
</a-form-item>
<!-- Tag -->
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-input v-model:value="outbound.tag" placeholder="unique-tag" />
</a-form-item>
<!-- Send through -->
<a-form-item label="Send through">
<a-input v-model:value="outbound.sendThrough" placeholder="local IP" />
</a-form-item>
<!-- ============== Freedom ============== -->
<template v-if="isFreedom">
<a-form-item label="Strategy">
<a-select v-model:value="outbound.settings.domainStrategy">
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Redirect">
<a-input v-model:value="outbound.settings.redirect" />
</a-form-item>
<a-divider :style="{ margin: '4px 0' }">Fragment</a-divider>
<a-form-item label="Fragment">
<a-switch :checked="!!outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0"
@change="(checked) => outbound.settings.fragment = checked ? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' } : {}" />
</a-form-item>
<template v-if="outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0">
<a-form-item label="Packets">
<a-select v-model:value="outbound.settings.fragment.packets">
<a-select-option v-for="p in ['1-3', 'tlshello']" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model:value="outbound.settings.fragment.length" placeholder="100-200" />
</a-form-item>
<a-form-item label="Interval">
<a-input v-model:value="outbound.settings.fragment.interval" placeholder="10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model:value="outbound.settings.fragment.maxSplit" placeholder="300-400" />
</a-form-item>
</template>
<a-divider :style="{ margin: '4px 0' }">Noises</a-divider>
<a-form-item label="Noises">
<a-switch :checked="(outbound.settings.noises || []).length > 0"
@change="(checked) => outbound.settings.noises = checked ? [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }] : []" />
<a-button v-if="outbound.settings.noises && outbound.settings.noises.length > 0" size="small"
type="primary" class="ml-8"
@click="outbound.settings.noises.push({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Noise {{ index + 1 }}
<DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
@click="outbound.settings.noises.splice(index, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select v-model:value="noise.type">
<a-select-option v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
}}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model:value="noise.packet" />
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input v-model:value="noise.delay" />
</a-form-item>
<a-form-item label="Apply to">
<a-select v-model:value="noise.applyTo">
<a-select-option v-for="x in ['ip', 'ipv4', 'ipv6']" :key="x" :value="x">{{ x }}</a-select-option>
</a-select>
</a-form-item>
</template>
</template>
<!-- ============== Blackhole ============== -->
<template v-if="isBlackhole">
<a-form-item label="Response Type">
<a-select v-model:value="outbound.settings.type">
<a-select-option v-for="x in ['', 'none', 'http']" :key="x" :value="x">{{ x || '(empty)'
}}</a-select-option>
</a-select>
</a-form-item>
</template>
<!-- ============== DNS ============== -->
<template v-if="isDNS">
<a-form-item :label="t('pages.inbounds.network')">
<a-select v-model:value="outbound.settings.network">
<a-select-option v-for="x in ['udp', 'tcp']" :key="x" :value="x">{{ x }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Rules">
<a-button size="small" type="primary"
@click="outbound.settings.rules.push({ action: 'direct', qtype: '', domain: '' })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Rule {{ index + 1 }}
<DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
</a-divider>
<a-form-item label="Action">
<a-select v-model:value="rule.action">
<a-select-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="QType">
<a-input v-model:value="rule.qtype" placeholder="1,3,23-24" />
</a-form-item>
<a-form-item :label="t('domainName')">
<a-input v-model:value="rule.domain" placeholder="domain:example.com" />
</a-form-item>
</template>
</template>
<!-- ============== WireGuard ============== -->
<template v-if="isWireguard">
<a-form-item :label="t('pages.inbounds.address')">
<a-input v-model:value="outbound.settings.address" />
</a-form-item>
<a-form-item>
<template #label>
{{ t('pages.inbounds.privatekey') }}
<SyncOutlined class="random-icon" @click="regenerateWgKeys" />
</template>
<a-input v-model:value="outbound.settings.secretKey" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-input :value="outbound.settings.pubKey" disabled />
</a-form-item>
<a-form-item label="Domain strategy">
<a-select v-model:value="outbound.settings.domainStrategy">
<a-select-option v-for="x in ['', ...WireguardDomainStrategy]" :key="x || '__'" :value="x">
{{ x || `(${t('none')})` }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="MTU">
<a-input-number v-model:value="outbound.settings.mtu" :min="0" />
</a-form-item>
<a-form-item label="Workers">
<a-input-number v-model:value="outbound.settings.workers" :min="0" />
</a-form-item>
<a-form-item label="No-kernel TUN">
<a-switch v-model:checked="outbound.settings.noKernelTun" />
</a-form-item>
<a-form-item label="Reserved">
<a-input v-model:value="outbound.settings.reserved" />
</a-form-item>
<a-form-item label="Peers">
<a-button size="small" type="primary"
@click="outbound.settings.peers.push({ endpoint: '', publicKey: '', psk: '', allowedIPs: [''], keepAlive: 0 })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Peer {{ index + 1 }}
<DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
@click="outbound.settings.peers.splice(index, 1)" />
</a-divider>
<a-form-item label="Endpoint">
<a-input v-model:value="peer.endpoint" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-input v-model:value="peer.publicKey" />
</a-form-item>
<a-form-item label="PSK">
<a-input v-model:value="peer.psk" />
</a-form-item>
<a-form-item label="Allowed IPs">
<template v-for="(_, idx) in peer.allowedIPs" :key="idx">
<a-input v-model:value="peer.allowedIPs[idx]" :style="{ marginBottom: '4px' }">
<template v-if="peer.allowedIPs.length > 1" #addonAfter>
<MinusOutlined @click="peer.allowedIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
<a-button size="small" @click="peer.allowedIPs.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item label="Keep alive">
<a-input-number v-model:value="peer.keepAlive" :min="0" />
</a-form-item>
</template>
</template>
<!-- ============== Address + Port (most protocols) ============== -->
<template v-if="outbound.hasAddressPort()">
<a-form-item :label="t('pages.inbounds.address')">
<a-input v-model:value="outbound.settings.address" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.port')">
<a-input-number v-model:value="outbound.settings.port" :min="1" :max="65535" />
</a-form-item>
</template>
<!-- ============== VMess / VLess user ============== -->
<template v-if="isVMessOrVLess">
<a-form-item label="ID">
<a-input v-model:value="outbound.settings.id" />
</a-form-item>
<a-form-item v-if="isVMess" :label="t('security')">
<a-select v-model:value="outbound.settings.security">
<a-select-option v-for="s in SECURITY_OPTIONS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isVLESS" :label="t('encryption')">
<a-input v-model:value="outbound.settings.encryption" />
</a-form-item>
<a-form-item v-if="isVLESS" label="Reverse tag">
<a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
</a-form-item>
<a-form-item v-if="outbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="outbound.settings.flow">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
</template>
<!-- ============== Trojan / Shadowsocks ============== -->
<template v-if="isTrojan || isShadowsocks">
<a-form-item :label="t('password')">
<a-input v-model:value="outbound.settings.password" />
</a-form-item>
</template>
<template v-if="isShadowsocks">
<a-form-item :label="t('encryption')">
<a-select v-model:value="outbound.settings.method">
<a-select-option v-for="(m, k) in SSMethods" :key="m" :value="m">{{ k }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="UDP over TCP">
<a-switch v-model:checked="outbound.settings.uot" />
</a-form-item>
<a-form-item label="UoT version">
<a-input-number v-model:value="outbound.settings.UoTVersion" :min="1" :max="2" />
</a-form-item>
</template>
<!-- ============== SOCKS / HTTP ============== -->
<template v-if="outbound.hasUsername()">
<a-form-item :label="t('username')">
<a-input v-model:value="outbound.settings.user" />
</a-form-item>
<a-form-item :label="t('password')">
<a-input v-model:value="outbound.settings.pass" />
</a-form-item>
</template>
<!-- ============== Hysteria ============== -->
<template v-if="isHysteria">
<a-form-item label="Version">
<a-input-number :value="outbound.settings.version || 2" :min="2" :max="2" disabled />
</a-form-item>
</template>
<!-- ============== Stream settings ============== -->
<template v-if="outbound.canEnableStream()">
<a-divider :style="{ margin: '4px 0' }">{{ t('transmission') }}</a-divider>
<a-form-item :label="t('transmission')">
<a-select :value="outbound.stream.network" @change="streamNetworkChange">
<a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
:value="net">
{{ NETWORK_LABELS[net] || net }}
</a-select-option>
</a-select>
</a-form-item>
<!-- TCP -->
<template v-if="outbound.stream.network === 'tcp'">
<a-form-item :label="`HTTP ${t('camouflage')}`">
<a-switch :checked="outbound.stream.tcp.type === 'http'"
@change="(checked) => outbound.stream.tcp.type = checked ? 'http' : 'none'" />
</a-form-item>
<template v-if="outbound.stream.tcp.type === 'http'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.tcp.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.tcp.path" />
</a-form-item>
</template>
</template>
<!-- KCP -->
<template v-if="outbound.stream.network === 'kcp'">
<a-form-item label="MTU">
<a-input-number v-model:value="outbound.stream.kcp.mtu" :min="0" />
</a-form-item>
<a-form-item label="TTI (ms)">
<a-input-number v-model:value="outbound.stream.kcp.tti" :min="0" />
</a-form-item>
<a-form-item label="Uplink (MB/s)">
<a-input-number v-model:value="outbound.stream.kcp.upCap" :min="0" />
</a-form-item>
<a-form-item label="Downlink (MB/s)">
<a-input-number v-model:value="outbound.stream.kcp.downCap" :min="0" />
</a-form-item>
<a-form-item label="CWND multiplier">
<a-input-number v-model:value="outbound.stream.kcp.cwndMultiplier" :min="1" />
</a-form-item>
<a-form-item label="Max sending window">
<a-input-number v-model:value="outbound.stream.kcp.maxSendingWindow" :min="0" />
</a-form-item>
</template>
<!-- WebSocket -->
<template v-if="outbound.stream.network === 'ws'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.ws.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.ws.path" />
</a-form-item>
<a-form-item label="Heartbeat (s)">
<a-input-number v-model:value="outbound.stream.ws.heartbeatPeriod" :min="0" />
</a-form-item>
</template>
<!-- gRPC -->
<template v-if="outbound.stream.network === 'grpc'">
<a-form-item label="Service name">
<a-input v-model:value="outbound.stream.grpc.serviceName" />
</a-form-item>
<a-form-item label="Authority">
<a-input v-model:value="outbound.stream.grpc.authority" />
</a-form-item>
<a-form-item label="Multi mode">
<a-switch v-model:checked="outbound.stream.grpc.multiMode" />
</a-form-item>
</template>
<!-- HTTPUpgrade -->
<template v-if="outbound.stream.network === 'httpupgrade'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.httpupgrade.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.httpupgrade.path" />
</a-form-item>
</template>
<!-- XHTTP -->
<template v-if="outbound.stream.network === 'xhttp'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.xhttp.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.xhttp.path" />
</a-form-item>
<a-form-item label="Mode">
<a-select v-model:value="outbound.stream.xhttp.mode">
<a-select-option v-for="m in Object.values(MODE_OPTION)" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding bytes">
<a-input v-model:value="outbound.stream.xhttp.xPaddingBytes" />
</a-form-item>
<a-form-item label="XMUX">
<a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
</a-form-item>
</template>
<!-- Hysteria transport -->
<template v-if="outbound.stream.network === 'hysteria'">
<a-form-item label="Auth password">
<a-input v-model:value="outbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item label="Congestion">
<a-select v-model:value="outbound.stream.hysteria.congestion">
<a-select-option value="">BBR (auto)</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Upload">
<a-input v-model:value="outbound.stream.hysteria.up" placeholder="100 mbps" />
</a-form-item>
<a-form-item label="Download">
<a-input v-model:value="outbound.stream.hysteria.down" placeholder="100 mbps" />
</a-form-item>
<a-form-item label="UDP hop port">
<a-input v-model:value="outbound.stream.hysteria.udphopPort" placeholder="1145-1919" />
</a-form-item>
<a-form-item label="Max idle (s)">
<a-input-number v-model:value="outbound.stream.hysteria.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep alive (s)">
<a-input-number v-model:value="outbound.stream.hysteria.keepAlivePeriod" :min="2" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU">
<a-switch v-model:checked="outbound.stream.hysteria.disablePathMTUDiscovery" />
</a-form-item>
</template>
</template>
<!-- ============== TLS / Reality ============== -->
<template v-if="outbound.canEnableTls()">
<a-divider :style="{ margin: '4px 0' }">{{ t('security') }}</a-divider>
<a-form-item :label="t('security')">
<a-radio-group v-model:value="outbound.stream.security" button-style="solid">
<a-radio-button value="none">{{ t('none') }}</a-radio-button>
<a-radio-button value="tls">TLS</a-radio-button>
<a-radio-button v-if="outbound.canEnableReality()" value="reality">Reality</a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="outbound.stream.isTls">
<a-form-item label="SNI">
<a-input v-model:value="outbound.stream.tls.serverName" placeholder="server name" />
</a-form-item>
<a-form-item label="uTLS">
<a-select v-model:value="outbound.stream.tls.fingerprint">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="ALPN">
<a-select v-model:value="outbound.stream.tls.alpn" mode="multiple">
<a-select-option v-for="alpn in ALPN_OPTIONS" :key="alpn" :value="alpn">{{ alpn }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="ECH">
<a-input v-model:value="outbound.stream.tls.echConfigList" />
</a-form-item>
<a-form-item label="Verify peer name">
<a-input v-model:value="outbound.stream.tls.verifyPeerCertByName" placeholder="cloudflare-dns.com" />
</a-form-item>
<a-form-item label="Pinned SHA256">
<a-input v-model:value="outbound.stream.tls.pinnedPeerCertSha256" placeholder="base64 SHA256" />
</a-form-item>
</template>
<template v-if="outbound.stream.isReality">
<a-form-item label="SNI">
<a-input v-model:value="outbound.stream.reality.serverName" />
</a-form-item>
<a-form-item label="uTLS">
<a-select v-model:value="outbound.stream.reality.fingerprint">
<a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Short ID">
<a-input v-model:value="outbound.stream.reality.shortId" />
</a-form-item>
<a-form-item label="SpiderX">
<a-input v-model:value="outbound.stream.reality.spiderX" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-textarea v-model:value="outbound.stream.reality.publicKey" :auto-size="{ minRows: 2 }" />
</a-form-item>
<a-form-item label="mldsa65 verify">
<a-textarea v-model:value="outbound.stream.reality.mldsa65Verify" :auto-size="{ minRows: 2 }" />
</a-form-item>
</template>
</template>
<!-- ============== sockopt ============== -->
<template v-if="outbound.stream">
<a-divider :style="{ margin: '4px 0' }">Sockopts</a-divider>
<a-form-item label="Sockopts">
<a-switch v-model:checked="outbound.stream.sockoptSwitch" />
</a-form-item>
<template v-if="outbound.stream.sockoptSwitch">
<a-form-item label="Dialer proxy">
<a-input v-model:value="outbound.stream.sockopt.dialerProxy" />
</a-form-item>
<a-form-item label="Address+Port strategy">
<a-select v-model:value="outbound.stream.sockopt.addressPortStrategy">
<a-select-option v-for="key in Object.values(Address_Port_Strategy)" :key="key" :value="key">
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Keep alive interval">
<a-input-number v-model:value="outbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
</a-form-item>
<a-form-item label="TCP Fast Open">
<a-switch v-model:checked="outbound.stream.sockopt.tcpFastOpen" />
</a-form-item>
<a-form-item label="Multipath TCP">
<a-switch v-model:checked="outbound.stream.sockopt.tcpMptcp" />
</a-form-item>
<a-form-item label="Penetrate">
<a-switch v-model:checked="outbound.stream.sockopt.penetrate" />
</a-form-item>
</template>
</template>
<!-- ============== Mux ============== -->
<template v-if="outbound.canEnableMux()">
<a-divider :style="{ margin: '4px 0' }">{{ t('pages.settings.mux') }}</a-divider>
<a-form-item :label="t('pages.settings.mux')">
<a-switch v-model:checked="outbound.mux.enabled" />
</a-form-item>
<template v-if="outbound.mux.enabled">
<a-form-item label="Concurrency">
<a-input-number v-model:value="outbound.mux.concurrency" :min="-1" :max="1024" />
</a-form-item>
<a-form-item label="xudp concurrency">
<a-input-number v-model:value="outbound.mux.xudpConcurrency" :min="-1" :max="1024" />
</a-form-item>
<a-form-item label="xudp UDP 443">
<a-select v-model:value="outbound.mux.xudpProxyUDP443">
<a-select-option v-for="x in ['reject', 'allow', 'skip']" :key="x" :value="x">{{ x
}}</a-select-option>
</a-select>
</a-form-item>
</template>
</template>
</a-form>
<!-- ============== FinalMask (TCP/UDP masks + QUIC params) ============== -->
<FinalMaskForm v-if="outbound.stream" :stream="outbound.stream" :protocol="proto" />
</a-tab-pane>
<!-- ============================== JSON ============================== -->
<a-tab-pane key="2" tab="JSON">
<a-space direction="vertical" :size="10" :style="{ width: '100%', marginTop: '10px' }">
<a-input-search v-model:value="linkInput" placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
@search="convertLink">
<template #enterButton>
<a-button>Convert</a-button>
</template>
</a-input-search>
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
class="json-editor" />
</a-space>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<style scoped>
.random-icon {
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
margin-left: 4px;
}
.danger-icon {
cursor: pointer;
color: #ff4d4f;
margin-left: 8px;
}
.ml-8 {
margin-left: 8px;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
</style>