mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-17 00:05:56 +03:00
Collapsed repeated stream/sniffing/settings handling in InboundFormModal into shared helpers (stampAdvancedTextFor, parseAdvancedSliceWithLabel, compactAdvancedJson, withSaving) plus a wrapped-config factory for the single-key editors. Cuts ~120 lines from the script section with no behavior change. The advanced-panel subtitle and editor-meta text used a fixed dark color that was unreadable on the dark and ultra-dark modal backgrounds. Switched both to opacity-on-inherit so they pick up AntD's theme-aware foreground color, the same pattern .section-heading already uses.
2249 lines
95 KiB
Vue
2249 lines
95 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import dayjs from 'dayjs';
|
|
import { message } from 'ant-design-vue';
|
|
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
|
|
|
import {
|
|
HttpUtil,
|
|
RandomUtil,
|
|
NumberFormatter,
|
|
SizeFormatter,
|
|
Wireguard,
|
|
} from '@/utils';
|
|
import { getRandomRealityTarget } from '@/models/reality-targets';
|
|
import {
|
|
Inbound,
|
|
Protocols,
|
|
SSMethods,
|
|
USERS_SECURITY,
|
|
TLS_FLOW_CONTROL,
|
|
SNIFFING_OPTION,
|
|
TLS_VERSION_OPTION,
|
|
TLS_CIPHER_OPTION,
|
|
UTLS_FINGERPRINT,
|
|
ALPN_OPTION,
|
|
USAGE_OPTION,
|
|
DOMAIN_STRATEGY_OPTION,
|
|
TCP_CONGESTION_OPTION,
|
|
MODE_OPTION,
|
|
} from '@/models/inbound.js';
|
|
import { DBInbound } from '@/models/dbinbound.js';
|
|
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
|
import JsonEditor from '@/components/JsonEditor.vue';
|
|
import { useNodeList } from '@/composables/useNodeList.js';
|
|
|
|
const { t } = useI18n();
|
|
|
|
// Node selector — Phase 1 multi-node deployment. Shows all enabled
|
|
// nodes regardless of online state so the form is usable while a node
|
|
// is briefly offline; the backend's fail-fast path will surface the
|
|
// real error when the user submits.
|
|
const { nodes: availableNodes } = useNodeList();
|
|
const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable));
|
|
|
|
// Phase 5f-iii-b: structured per-protocol/per-transport forms instead
|
|
// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound
|
|
// pair so the existing model helpers (.toString(), .canEnableTls(),
|
|
// genAllLinks(), addPeer(), etc.) keep working unchanged. The
|
|
// "Advanced" tab still exposes the full streamSettings JSON for
|
|
// transport variants (KCP/XHTTP/sockopt/finalmask) we don't yet have
|
|
// dedicated UI for.
|
|
|
|
const props = defineProps({
|
|
open: { type: Boolean, default: false },
|
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
|
dbInbound: { type: Object, default: null },
|
|
});
|
|
|
|
const emit = defineEmits(['update:open', 'saved']);
|
|
|
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
|
const PROTOCOLS = Object.values(Protocols);
|
|
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
|
|
|
// === Reactive state ================================================
|
|
// Cloned on every open so cancelling the modal doesn't mutate the row.
|
|
const inbound = ref(null);
|
|
const dbForm = ref(null);
|
|
const saving = ref(false);
|
|
const advancedStreamText = ref('');
|
|
const advancedSniffingText = ref('');
|
|
const advancedSettingsText = ref('');
|
|
const activeTabKey = ref('basic');
|
|
const advancedSectionKey = ref('all');
|
|
// Cached default cert/key paths from /panel/setting/defaultSettings —
|
|
// powers the "Set default cert" button on the TLS form.
|
|
const defaultCert = ref('');
|
|
const defaultKey = ref('');
|
|
|
|
// Lookup tables for the option dropdowns.
|
|
const TLS_VERSIONS = Object.values(TLS_VERSION_OPTION);
|
|
const CIPHER_SUITES = Object.entries(TLS_CIPHER_OPTION); // [label, value]
|
|
const FINGERPRINTS = Object.values(UTLS_FINGERPRINT);
|
|
const ALPNS = Object.values(ALPN_OPTION);
|
|
const USAGES = Object.values(USAGE_OPTION);
|
|
const DOMAIN_STRATEGIES = Object.values(DOMAIN_STRATEGY_OPTION);
|
|
const TCP_CONGESTIONS = Object.values(TCP_CONGESTION_OPTION);
|
|
const MODE_OPTIONS = Object.values(MODE_OPTION);
|
|
|
|
// External proxy is a single switch in the UI but a list in the model:
|
|
// flipping it on seeds one row pre-filled with the current host:port.
|
|
const externalProxy = computed({
|
|
get: () => Array.isArray(inbound.value?.stream?.externalProxy)
|
|
&& inbound.value.stream.externalProxy.length > 0,
|
|
set: (v) => {
|
|
if (!inbound.value?.stream) return;
|
|
if (v) {
|
|
inbound.value.stream.externalProxy = [{
|
|
forceTls: 'same',
|
|
dest: window.location.hostname,
|
|
port: inbound.value.port,
|
|
remark: '',
|
|
}];
|
|
} else {
|
|
inbound.value.stream.externalProxy = [];
|
|
}
|
|
},
|
|
});
|
|
|
|
// Derived helpers — each is a computed off `inbound` so flips of
|
|
// protocol / network / security re-render the right blocks.
|
|
const protocol = computed(() => inbound.value?.protocol);
|
|
const network = computed({
|
|
get: () => inbound.value?.stream?.network,
|
|
set: (v) => onNetworkChange(v),
|
|
});
|
|
const security = computed({
|
|
get: () => inbound.value?.stream?.security,
|
|
set: (v) => { if (inbound.value?.stream) inbound.value.stream.security = v; },
|
|
});
|
|
|
|
const isMultiUser = computed(() => {
|
|
if (!inbound.value) return false;
|
|
switch (inbound.value.protocol) {
|
|
case Protocols.VMESS:
|
|
case Protocols.VLESS:
|
|
case Protocols.TROJAN:
|
|
case Protocols.HYSTERIA:
|
|
return true;
|
|
case Protocols.SHADOWSOCKS:
|
|
return !!inbound.value.isSSMultiUser;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const clientsArray = computed(() => {
|
|
if (!inbound.value) return [];
|
|
switch (inbound.value.protocol) {
|
|
case Protocols.VMESS: return inbound.value.settings.vmesses || [];
|
|
case Protocols.VLESS: return inbound.value.settings.vlesses || [];
|
|
case Protocols.TROJAN: return inbound.value.settings.trojans || [];
|
|
case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || [];
|
|
case Protocols.HYSTERIA: return inbound.value.settings.hysterias || [];
|
|
default: return [];
|
|
}
|
|
});
|
|
|
|
const firstClient = computed(() => clientsArray.value[0] || null);
|
|
const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true);
|
|
const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
|
|
const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true);
|
|
const canEnableTlsFlow = computed(() => inbound.value?.canEnableTlsFlow?.() === true);
|
|
|
|
// VLESS/Trojan TLS fallbacks — surfaced in the protocol tab when the
|
|
// inbound is on TCP and (for VLESS) using no Xray-side encryption.
|
|
const showFallbacks = computed(() => {
|
|
if (!inbound.value) return false;
|
|
if (inbound.value.stream?.network !== 'tcp') return false;
|
|
if (inbound.value.protocol === Protocols.VLESS) {
|
|
const enc = inbound.value.settings?.encryption;
|
|
return !enc || enc === 'none';
|
|
}
|
|
return inbound.value.protocol === Protocols.TROJAN;
|
|
});
|
|
|
|
function addFallback() {
|
|
inbound.value?.settings?.addFallback?.();
|
|
}
|
|
function delFallback(idx) {
|
|
inbound.value?.settings?.delFallback?.(idx);
|
|
}
|
|
|
|
// Date / GB bridges (legacy used moment via _expiryTime; we go direct).
|
|
const expiryDate = computed({
|
|
get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null),
|
|
set: (next) => { if (dbForm.value) dbForm.value.expiryTime = next ? next.valueOf() : 0; },
|
|
});
|
|
const totalGB = computed({
|
|
get: () => (dbForm.value?.total ? Math.round((dbForm.value.total / SizeFormatter.ONE_GB) * 100) / 100 : 0),
|
|
set: (gb) => { if (dbForm.value) dbForm.value.total = NumberFormatter.toFixed((gb || 0) * SizeFormatter.ONE_GB, 0); },
|
|
});
|
|
|
|
// Client total/expiry bridges (only relevant in add mode for new clients)
|
|
const clientExpiryDate = computed({
|
|
get: () => (firstClient.value?.expiryTime > 0 ? dayjs(firstClient.value.expiryTime) : null),
|
|
set: (next) => { if (firstClient.value) firstClient.value.expiryTime = next ? next.valueOf() : 0; },
|
|
});
|
|
const clientTotalGB = computed({
|
|
get: () => firstClient.value?._totalGB ?? 0,
|
|
set: (gb) => { if (firstClient.value) firstClient.value._totalGB = gb || 0; },
|
|
});
|
|
|
|
// === Open / state management =======================================
|
|
function loadFromDbInbound(dbIn) {
|
|
// Round-trip through Inbound.fromJson so subsequent edits get the
|
|
// structured class hierarchy (StreamSettings, TLS, Reality, etc.).
|
|
const parsed = Inbound.fromJson(dbIn.toInbound().toJson());
|
|
inbound.value = parsed;
|
|
// DBForm carries the persisted-fields the parsed Inbound doesn't:
|
|
// remark, enable, total, expiryTime, trafficReset, etc.
|
|
dbForm.value = new DBInbound(dbIn);
|
|
primeAdvancedJson();
|
|
}
|
|
|
|
function makeFreshInbound(proto) {
|
|
const ib = new Inbound();
|
|
ib.protocol = proto;
|
|
ib.settings = Inbound.Settings.getSettings(proto);
|
|
ib.port = RandomUtil.randomInteger(10000, 60000);
|
|
return ib;
|
|
}
|
|
|
|
function freshDbForm() {
|
|
const next = new DBInbound();
|
|
next.enable = true;
|
|
next.remark = '';
|
|
next.total = 0;
|
|
next.expiryTime = 0;
|
|
next.trafficReset = 'never';
|
|
return next;
|
|
}
|
|
|
|
function primeAdvancedJson() {
|
|
if (!inbound.value) return;
|
|
['stream', 'sniffing', 'settings'].forEach(stampAdvancedTextFor);
|
|
}
|
|
|
|
watch(() => props.open, (next) => {
|
|
if (!next) return;
|
|
if (props.mode === 'edit' && props.dbInbound) {
|
|
loadFromDbInbound(props.dbInbound);
|
|
} else {
|
|
inbound.value = makeFreshInbound(Protocols.VLESS);
|
|
dbForm.value = freshDbForm();
|
|
primeAdvancedJson();
|
|
}
|
|
activeTabKey.value = 'basic';
|
|
advancedSectionKey.value = 'all';
|
|
fetchDefaultCertSettings();
|
|
});
|
|
|
|
function applyAdvancedJsonToBasic() {
|
|
if (!inbound.value) return true;
|
|
let settings; let streamSettings; let sniffing;
|
|
try {
|
|
settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings');
|
|
streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream');
|
|
sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing');
|
|
} catch (_e) { return false; }
|
|
|
|
try {
|
|
inbound.value = Inbound.fromJson({
|
|
port: inbound.value.port,
|
|
listen: inbound.value.listen,
|
|
protocol: inbound.value.protocol,
|
|
settings,
|
|
streamSettings,
|
|
tag: inbound.value.tag,
|
|
sniffing,
|
|
clientStats: inbound.value.clientStats,
|
|
});
|
|
} catch (e) {
|
|
message.error(`Advanced JSON: ${e.message}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
let isRevertingTab = false;
|
|
watch(activeTabKey, (next, prev) => {
|
|
if (isRevertingTab) { isRevertingTab = false; return; }
|
|
if (prev === 'advanced' && next !== 'advanced') {
|
|
if (!applyAdvancedJsonToBasic()) {
|
|
isRevertingTab = true;
|
|
activeTabKey.value = 'advanced';
|
|
}
|
|
}
|
|
});
|
|
|
|
// In add mode, switching protocol restamps settings + re-syncs port.
|
|
function onProtocolChange(next) {
|
|
if (props.mode === 'edit' || !inbound.value) return;
|
|
inbound.value.protocol = next;
|
|
inbound.value.settings = Inbound.Settings.getSettings(next);
|
|
primeAdvancedJson();
|
|
}
|
|
|
|
function onNetworkChange(next) {
|
|
if (!inbound.value?.stream) return;
|
|
inbound.value.stream.network = next;
|
|
// Mirror legacy streamNetworkChange: clear flow when TLS/Reality
|
|
// become unavailable; reset finalmask.udp when not KCP.
|
|
if (!inbound.value.canEnableTls()) inbound.value.stream.security = 'none';
|
|
if (!inbound.value.canEnableReality()) inbound.value.reality = false;
|
|
if (
|
|
inbound.value.protocol === Protocols.VLESS
|
|
&& !inbound.value.canEnableTlsFlow()
|
|
&& Array.isArray(inbound.value.settings.vlesses)
|
|
) {
|
|
inbound.value.settings.vlesses.forEach((c) => { c.flow = ''; });
|
|
}
|
|
if (next !== 'kcp' && inbound.value.stream.finalmask) {
|
|
inbound.value.stream.finalmask.udp = [];
|
|
}
|
|
}
|
|
|
|
function parseAdvancedSliceOrFallback(rawText, fallbackValue) {
|
|
if (!rawText?.trim()) return fallbackValue;
|
|
return JSON.parse(rawText);
|
|
}
|
|
|
|
function unwrapWrappedObject(parsed, key) {
|
|
if (
|
|
parsed
|
|
&& typeof parsed === 'object'
|
|
&& !Array.isArray(parsed)
|
|
&& parsed[key] !== undefined
|
|
) {
|
|
return parsed[key];
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
const settingsFallback = () => inbound.value?.settings?.toJson?.() || {};
|
|
const sniffingFallback = () => inbound.value?.sniffing?.toJson?.() || {};
|
|
const streamFallback = () => inbound.value?.stream?.toJson?.() || {};
|
|
|
|
const advancedTextRefs = {
|
|
stream: advancedStreamText,
|
|
sniffing: advancedSniffingText,
|
|
settings: advancedSettingsText,
|
|
};
|
|
|
|
function stampAdvancedTextFor(slice) {
|
|
const textRef = advancedTextRefs[slice];
|
|
if (!textRef) return;
|
|
if (slice === 'stream' && !canEnableStream.value) {
|
|
textRef.value = '{}';
|
|
return;
|
|
}
|
|
const obj = inbound.value?.[slice];
|
|
if (!obj) return;
|
|
try {
|
|
textRef.value = JSON.stringify(JSON.parse(obj.toString()), null, 2);
|
|
} catch (_e) { /* keep prior text */ }
|
|
}
|
|
|
|
function parseAdvancedSliceWithLabel(rawText, fallback, label) {
|
|
try {
|
|
return parseAdvancedSliceOrFallback(rawText, fallback);
|
|
} catch (e) {
|
|
message.error(`${label} JSON invalid: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function compactAdvancedJson(raw, fallback, label) {
|
|
try {
|
|
return JSON.stringify(JSON.parse(raw || fallback));
|
|
} catch (e) {
|
|
message.error(`${label} JSON invalid: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function withSaving(fn) {
|
|
saving.value = true;
|
|
try { return await fn(); } finally { saving.value = false; }
|
|
}
|
|
|
|
function makeWrappedAdvancedConfig({ key, textRef, getFallback, label }) {
|
|
const invalid = `${label} JSON invalid`;
|
|
return computed({
|
|
get: () => {
|
|
if (!inbound.value) return '';
|
|
try {
|
|
const value = parseAdvancedSliceOrFallback(textRef.value, getFallback());
|
|
return JSON.stringify({ [key]: value }, null, 2);
|
|
} catch (_e) {
|
|
return '';
|
|
}
|
|
},
|
|
set: (next) => {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(next);
|
|
} catch (e) {
|
|
message.error(`${invalid}: ${e.message}`);
|
|
return;
|
|
}
|
|
const unwrapped = unwrapWrappedObject(parsed, key);
|
|
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
|
|
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
|
|
return;
|
|
}
|
|
try {
|
|
textRef.value = JSON.stringify(unwrapped, null, 2);
|
|
} catch (e) {
|
|
message.error(`${invalid}: ${e.message}`);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
const advancedAllConfig = computed({
|
|
get: () => {
|
|
if (!inbound.value) return '';
|
|
try {
|
|
const result = {
|
|
listen: inbound.value.listen,
|
|
port: inbound.value.port,
|
|
protocol: inbound.value.protocol,
|
|
settings: parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback()),
|
|
sniffing: parseAdvancedSliceOrFallback(advancedSniffingText.value, sniffingFallback()),
|
|
tag: inbound.value.tag,
|
|
};
|
|
if (canEnableStream.value) {
|
|
result.streamSettings = parseAdvancedSliceOrFallback(advancedStreamText.value, streamFallback());
|
|
}
|
|
return JSON.stringify(result, null, 2);
|
|
} catch (_e) {
|
|
return '';
|
|
}
|
|
},
|
|
set: (next) => {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(next);
|
|
} catch (e) {
|
|
message.error(`All JSON invalid: ${e.message}`);
|
|
return;
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
message.error('All JSON must be an inbound object.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (typeof parsed.listen === 'string') inbound.value.listen = parsed.listen;
|
|
if (parsed.port !== undefined) {
|
|
const port = Number(parsed.port);
|
|
if (Number.isFinite(port)) inbound.value.port = port;
|
|
}
|
|
if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
|
|
inbound.value.protocol = parsed.protocol;
|
|
}
|
|
if (typeof parsed.tag === 'string') inbound.value.tag = parsed.tag;
|
|
|
|
const existingSettings = parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback());
|
|
advancedSettingsText.value = JSON.stringify(parsed.settings ?? existingSettings, null, 2);
|
|
advancedSniffingText.value = JSON.stringify(parsed.sniffing ?? sniffingFallback(), null, 2);
|
|
advancedStreamText.value = canEnableStream.value
|
|
? JSON.stringify(parsed.streamSettings ?? streamFallback(), null, 2)
|
|
: '{}';
|
|
} catch (e) {
|
|
message.error(`All JSON invalid: ${e.message}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
const advancedSettingsConfig = makeWrappedAdvancedConfig({
|
|
key: 'settings',
|
|
textRef: advancedSettingsText,
|
|
getFallback: settingsFallback,
|
|
label: 'Settings',
|
|
});
|
|
|
|
const advancedSniffingConfig = makeWrappedAdvancedConfig({
|
|
key: 'sniffing',
|
|
textRef: advancedSniffingText,
|
|
getFallback: sniffingFallback,
|
|
label: 'Sniffing',
|
|
});
|
|
|
|
const advancedStreamConfig = makeWrappedAdvancedConfig({
|
|
key: 'streamSettings',
|
|
textRef: advancedStreamText,
|
|
getFallback: streamFallback,
|
|
label: 'Stream',
|
|
});
|
|
|
|
// === Random helpers wired to the form's sync icons ==================
|
|
function randomEmail(target) {
|
|
if (target) target.email = RandomUtil.randomLowerAndNum(9);
|
|
}
|
|
function randomUuid(target) {
|
|
if (target) target.id = RandomUtil.randomUUID();
|
|
}
|
|
function randomPasswordSeq(target, len = 10) {
|
|
if (target) target.password = RandomUtil.randomSeq(len);
|
|
}
|
|
function randomSSPassword(target) {
|
|
if (target) target.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
|
}
|
|
function randomAuth(target) {
|
|
if (target) target.auth = RandomUtil.randomSeq(10);
|
|
}
|
|
function randomSubId(target) {
|
|
if (target) target.subId = RandomUtil.randomLowerAndNum(16);
|
|
}
|
|
function regenWgKeypair(target) {
|
|
const kp = Wireguard.generateKeypair();
|
|
target.publicKey = kp.publicKey;
|
|
target.privateKey = kp.privateKey;
|
|
}
|
|
function regenInboundWg() {
|
|
const kp = Wireguard.generateKeypair();
|
|
inbound.value.settings.pubKey = kp.publicKey;
|
|
inbound.value.settings.secretKey = kp.privateKey;
|
|
}
|
|
|
|
// === Reality keygen via existing API =================================
|
|
async function genRealityKeypair() {
|
|
await withSaving(async () => {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
|
if (msg?.success) {
|
|
inbound.value.stream.reality.privateKey = msg.obj.privateKey;
|
|
inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearRealityKeypair() {
|
|
if (!inbound.value?.stream?.reality) return;
|
|
inbound.value.stream.reality.privateKey = '';
|
|
inbound.value.stream.reality.settings.publicKey = '';
|
|
}
|
|
|
|
async function genMldsa65() {
|
|
await withSaving(async () => {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
|
|
if (msg?.success) {
|
|
inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
|
|
inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearMldsa65() {
|
|
if (!inbound.value?.stream?.reality) return;
|
|
inbound.value.stream.reality.mldsa65Seed = '';
|
|
inbound.value.stream.reality.settings.mldsa65Verify = '';
|
|
}
|
|
|
|
function randomizeRealityTarget() {
|
|
if (!inbound.value?.stream?.reality) return;
|
|
const t = getRandomRealityTarget();
|
|
inbound.value.stream.reality.target = t.target;
|
|
inbound.value.stream.reality.serverNames = t.sni;
|
|
}
|
|
|
|
function randomizeShortIds() {
|
|
if (!inbound.value?.stream?.reality) return;
|
|
inbound.value.stream.reality.shortIds = RandomUtil.randomShortIds();
|
|
}
|
|
|
|
// === ECH cert helpers ================================================
|
|
async function getNewEchCert() {
|
|
if (!inbound.value?.stream?.tls) return;
|
|
await withSaving(async () => {
|
|
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
|
|
sni: inbound.value.stream.tls.sni,
|
|
});
|
|
if (msg?.success) {
|
|
inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
|
|
inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearEchCert() {
|
|
if (!inbound.value?.stream?.tls) return;
|
|
inbound.value.stream.tls.echServerKeys = '';
|
|
inbound.value.stream.tls.settings.echConfigList = '';
|
|
}
|
|
|
|
function setDefaultCertData(idx) {
|
|
if (!inbound.value?.stream?.tls?.certs?.[idx]) return;
|
|
inbound.value.stream.tls.certs[idx].certFile = defaultCert.value;
|
|
inbound.value.stream.tls.certs[idx].keyFile = defaultKey.value;
|
|
}
|
|
|
|
async function fetchDefaultCertSettings() {
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
|
if (msg?.success && msg.obj) {
|
|
defaultCert.value = msg.obj.defaultCert || '';
|
|
defaultKey.value = msg.obj.defaultKey || '';
|
|
}
|
|
} catch (_e) { /* non-fatal — leave Set Default disabled */ }
|
|
}
|
|
|
|
// === VLESS encryption helpers =======================================
|
|
// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
|
|
// call; the user clicks one button to pick which block goes into
|
|
// decryption/encryption. Both generated strings share the same hybrid
|
|
// mlkem768x25519plus prefix; the auth choice is the final key block.
|
|
function normalizeVlessAuthLabel(label = '') {
|
|
return label.toLowerCase().replace(/[-_\s]/g, '');
|
|
}
|
|
|
|
function matchesVlessAuth(block, authId) {
|
|
if (block?.id === authId) return true;
|
|
const label = normalizeVlessAuthLabel(block?.label);
|
|
if (authId === 'mlkem768') return label.includes('mlkem768');
|
|
if (authId === 'x25519') return label.includes('x25519');
|
|
return false;
|
|
}
|
|
|
|
async function getNewVlessEnc(authId) {
|
|
if (!authId || !inbound.value?.settings) return;
|
|
await withSaving(async () => {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
|
if (!msg?.success) return;
|
|
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
|
|
if (!block) return;
|
|
inbound.value.settings.decryption = block.decryption;
|
|
inbound.value.settings.encryption = block.encryption;
|
|
});
|
|
}
|
|
|
|
function clearVlessEnc() {
|
|
if (!inbound.value?.settings) return;
|
|
inbound.value.settings.decryption = 'none';
|
|
inbound.value.settings.encryption = 'none';
|
|
}
|
|
|
|
const selectedVlessAuth = computed(() => {
|
|
const encryption = inbound.value?.settings?.encryption;
|
|
if (!encryption || encryption === 'none') return 'None';
|
|
|
|
const parts = encryption.split('.').filter(Boolean);
|
|
const authKey = parts[parts.length - 1] || '';
|
|
if (!authKey) return 'Custom';
|
|
|
|
return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth';
|
|
});
|
|
|
|
// === SS method change tracks legacy semantics =========================
|
|
function onSSMethodChange() {
|
|
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
|
if (inbound.value.isSSMultiUser) {
|
|
if (inbound.value.settings.shadowsockses.length === 0) {
|
|
inbound.value.settings.shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()];
|
|
}
|
|
inbound.value.settings.shadowsockses.forEach((c) => {
|
|
c.method = inbound.value.isSS2022 ? '' : inbound.value.settings.method;
|
|
c.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
|
});
|
|
} else {
|
|
inbound.value.settings.shadowsockses = [];
|
|
}
|
|
}
|
|
|
|
// === Submit ==========================================================
|
|
function close() {
|
|
emit('update:open', false);
|
|
}
|
|
|
|
async function submit() {
|
|
if (!inbound.value || !dbForm.value) return;
|
|
saving.value = true;
|
|
try {
|
|
let streamSettings; let sniffing; let settings;
|
|
try {
|
|
streamSettings = canEnableStream.value
|
|
? compactAdvancedJson(advancedStreamText.value, '', 'Stream')
|
|
: (inbound.value.stream?.sockopt
|
|
? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
|
|
: '');
|
|
sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing');
|
|
settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings');
|
|
} catch (_e) { return; }
|
|
|
|
// The structured form mutates `inbound.stream` directly when the
|
|
// user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
|
|
// the Advanced JSON tab their edits live there. Keep the JSON tab
|
|
// authoritative — it was populated from the live model on open
|
|
// and watch handlers below sync in either direction.
|
|
const payload = {
|
|
up: dbForm.value.up || 0,
|
|
down: dbForm.value.down || 0,
|
|
total: dbForm.value.total,
|
|
remark: dbForm.value.remark,
|
|
enable: dbForm.value.enable,
|
|
expiryTime: dbForm.value.expiryTime,
|
|
trafficReset: dbForm.value.trafficReset,
|
|
lastTrafficResetTime: dbForm.value.lastTrafficResetTime || 0,
|
|
listen: inbound.value.listen,
|
|
port: inbound.value.port,
|
|
protocol: inbound.value.protocol,
|
|
settings: settings,
|
|
streamSettings: streamSettings,
|
|
sniffing: sniffing,
|
|
};
|
|
// Multi-node deployment: only include nodeId when the user picked a
|
|
// remote node. Sending nodeId=null over qs.stringify becomes an
|
|
// empty form value, which Go's form binding for *int parses as 0
|
|
// — not nil — and we'd then try to look up node id 0 and fail with
|
|
// "record not found". Omitting the key entirely keeps NodeID nil.
|
|
if (dbForm.value.nodeId != null) {
|
|
payload.nodeId = dbForm.value.nodeId;
|
|
}
|
|
|
|
const url = props.mode === 'edit'
|
|
? `/panel/api/inbounds/update/${props.dbInbound.id}`
|
|
: '/panel/api/inbounds/add';
|
|
const msg = await HttpUtil.post(url, payload);
|
|
if (msg?.success) {
|
|
emit('saved');
|
|
close();
|
|
}
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
const title = computed(() =>
|
|
props.mode === 'edit'
|
|
? t('pages.inbounds.modifyInbound')
|
|
: t('pages.inbounds.addInbound'),
|
|
);
|
|
const okText = computed(() =>
|
|
props.mode === 'edit' ? t('pages.client.submitEdit') : t('create'),
|
|
);
|
|
|
|
// Whenever the structured form mutates stream / sniffing / settings,
|
|
// refresh the matching slice of the Advanced JSON tab so the user
|
|
// always sees the live state.
|
|
['stream', 'sniffing', 'settings'].forEach((slice) => {
|
|
watch(
|
|
() => inbound.value && JSON.stringify(inbound.value[slice]?.toJson?.() || {}),
|
|
() => stampAdvancedTextFor(slice),
|
|
);
|
|
});
|
|
|
|
watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
|
</script>
|
|
|
|
<template>
|
|
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
|
|
:mask-closable="false" width="780px" @ok="submit" @cancel="close">
|
|
<a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
|
|
<!-- ============================== BASICS ============================== -->
|
|
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
|
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
<a-form-item :label="t('enable')">
|
|
<a-switch v-model:checked="dbForm.enable" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.remark')">
|
|
<a-input v-model:value="dbForm.remark" />
|
|
</a-form-item>
|
|
<a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
|
|
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
|
|
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
|
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
|
<a-select-option v-for="n in selectableNodes" :key="n.id" :value="n.id"
|
|
:disabled="n.status === 'offline'">
|
|
{{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.protocol')">
|
|
<a-select :value="protocol" :disabled="mode === 'edit'" @change="onProtocolChange">
|
|
<a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.address')">
|
|
<a-input v-model:value="inbound.listen" :placeholder="t('pages.inbounds.monitorDesc')" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.port')">
|
|
<a-input-number v-model:value="inbound.port" :min="1" :max="65535" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
|
</template>
|
|
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.periodicTrafficResetTitle')">
|
|
<a-select v-model:value="dbForm.trafficReset">
|
|
<a-select-option v-for="r in TRAFFIC_RESETS" :key="r" :value="r">
|
|
{{ t(`pages.inbounds.periodicTrafficReset.${r}`) }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
|
}}</a-tooltip>
|
|
</template>
|
|
<DateTimePicker v-model:value="expiryDate" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-tab-pane>
|
|
|
|
<!-- ============================== PROTOCOL ============================== -->
|
|
<a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
|
|
<!-- Multi-user inbounds: in add mode embed the first client form,
|
|
in edit mode show a count summary. -->
|
|
<template v-if="isMultiUser">
|
|
<a-collapse v-if="mode === 'add' && firstClient" default-active-key="0">
|
|
<a-collapse-panel key="0" header="Client">
|
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
<a-form-item label="Enable">
|
|
<a-switch v-model:checked="firstClient.enable" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip title="Friendly identifier">
|
|
Email
|
|
<SyncOutlined class="random-icon" @click="randomEmail(firstClient)" />
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value="firstClient.email" />
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="protocol === Protocols.VMESS || protocol === Protocols.VLESS">
|
|
<template #label>
|
|
<a-tooltip title="Reset to a fresh UUID">
|
|
ID
|
|
<SyncOutlined class="random-icon" @click="randomUuid(firstClient)" />
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value="firstClient.id" />
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="protocol === Protocols.VMESS" label="Security">
|
|
<a-select v-model:value="firstClient.security">
|
|
<a-select-option v-for="k in SECURITY_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS">
|
|
<template #label>
|
|
<a-tooltip title="Reset to a fresh random value">
|
|
Password
|
|
<SyncOutlined v-if="protocol === Protocols.SHADOWSOCKS" class="random-icon"
|
|
@click="randomSSPassword(firstClient)" />
|
|
<SyncOutlined v-else class="random-icon" @click="randomPasswordSeq(firstClient)" />
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value="firstClient.password" />
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
|
<template #label>
|
|
<a-tooltip title="Reset"><span>Auth password</span>
|
|
<SyncOutlined class="random-icon" @click="randomAuth(firstClient)" />
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value="firstClient.auth" />
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="canEnableTlsFlow" label="Flow">
|
|
<a-select v-model:value="firstClient.flow">
|
|
<a-select-option value="">none</a-select-option>
|
|
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
|
<a-input v-model:value="firstClient.reverseTag" placeholder="Optional reverse tag" />
|
|
</a-form-item>
|
|
|
|
<a-form-item label="Subscription">
|
|
<a-input v-model:value="firstClient.subId">
|
|
<template #addonAfter>
|
|
<SyncOutlined class="random-icon" @click="randomSubId(firstClient)" />
|
|
</template>
|
|
</a-input>
|
|
</a-form-item>
|
|
|
|
<a-form-item label="Comment">
|
|
<a-input v-model:value="firstClient.comment" />
|
|
</a-form-item>
|
|
|
|
<a-form-item label="Total traffic (GB)">
|
|
<a-input-number v-model:value="clientTotalGB" :min="0" :step="0.1" />
|
|
</a-form-item>
|
|
|
|
<a-form-item label="Expiry">
|
|
<DateTimePicker v-model:value="clientExpiryDate" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-collapse-panel>
|
|
</a-collapse>
|
|
|
|
<a-collapse v-else>
|
|
<a-collapse-panel key="summary" :header="`Clients: ${clientsArray.length}`">
|
|
<table class="client-summary">
|
|
<thead>
|
|
<tr>
|
|
<th>Email</th>
|
|
<th>{{ protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS ? 'Password' : (protocol
|
|
===
|
|
Protocols.HYSTERIA ? 'Auth' : 'ID') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(c, idx) in clientsArray" :key="idx">
|
|
<td>{{ c.email }}</td>
|
|
<td>{{ c.id || c.password || c.auth }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</a-collapse-panel>
|
|
</a-collapse>
|
|
</template>
|
|
|
|
<!-- VLess decryption / encryption -->
|
|
<a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item label="Decryption">
|
|
<a-input v-model:value="inbound.settings.decryption" />
|
|
</a-form-item>
|
|
<a-form-item label="Encryption">
|
|
<a-input v-model:value="inbound.settings.encryption" />
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-space :size="8" wrap>
|
|
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
|
|
X25519 auth
|
|
</a-button>
|
|
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
|
|
ML-KEM-768 auth
|
|
</a-button>
|
|
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
|
</a-space>
|
|
<a-typography-text type="secondary" class="vless-auth-state">
|
|
Selected: {{ selectedVlessAuth }}
|
|
</a-typography-text>
|
|
</a-form-item>
|
|
</a-form>
|
|
|
|
<!-- Shadowsocks shared fields (method/network/ivCheck) -->
|
|
<a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item label="Encryption method">
|
|
<a-select v-model:value="inbound.settings.method" @change="onSSMethodChange">
|
|
<a-select-option v-for="(m, k) in SSMethods" :key="k" :value="m">{{ k }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.isSS2022">
|
|
<template #label>
|
|
Password
|
|
<SyncOutlined class="random-icon" @click="randomSSPassword(inbound.settings)" />
|
|
</template>
|
|
<a-input v-model:value="inbound.settings.password" />
|
|
</a-form-item>
|
|
<a-form-item label="Network">
|
|
<a-select v-model:value="inbound.settings.network" :style="{ width: '120px' }">
|
|
<a-select-option value="tcp,udp">TCP, UDP</a-select-option>
|
|
<a-select-option value="tcp">TCP</a-select-option>
|
|
<a-select-option value="udp">UDP</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="ivCheck">
|
|
<a-switch v-model:checked="inbound.settings.ivCheck" />
|
|
</a-form-item>
|
|
</a-form>
|
|
|
|
<!-- HTTP / Mixed accounts -->
|
|
<a-form v-if="protocol === Protocols.HTTP || protocol === Protocols.MIXED" :colon="false"
|
|
:label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item label="Accounts">
|
|
<a-button size="small" @click="protocol === Protocols.HTTP
|
|
? inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())
|
|
: inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
Add
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(account, idx) in inbound.settings.accounts" :key="idx" compact class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="account.user" placeholder="Username">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="account.pass" placeholder="Password" />
|
|
<a-button @click="inbound.settings.delAccount(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
<a-form-item v-if="protocol === Protocols.HTTP" label="Allow transparent">
|
|
<a-switch v-model:checked="inbound.settings.allowTransparent" />
|
|
</a-form-item>
|
|
<template v-if="protocol === Protocols.MIXED">
|
|
<a-form-item label="Auth">
|
|
<a-select v-model:value="inbound.settings.auth">
|
|
<a-select-option value="noauth">noauth</a-select-option>
|
|
<a-select-option value="password">password</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="UDP">
|
|
<a-switch v-model:checked="inbound.settings.udp" />
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.settings.udp" label="UDP IP">
|
|
<a-input v-model:value="inbound.settings.ip" />
|
|
</a-form-item>
|
|
</template>
|
|
</a-form>
|
|
|
|
<!-- Tunnel -->
|
|
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item label="Rewrite address">
|
|
<a-input v-model:value="inbound.settings.rewriteAddress" />
|
|
</a-form-item>
|
|
<a-form-item label="Rewrite port">
|
|
<a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
|
|
</a-form-item>
|
|
<a-form-item label="Allowed network">
|
|
<a-select v-model:value="inbound.settings.allowedNetwork">
|
|
<a-select-option value="tcp,udp">TCP, UDP</a-select-option>
|
|
<a-select-option value="tcp">TCP</a-select-option>
|
|
<a-select-option value="udp">UDP</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="Port map">
|
|
<a-button size="small" @click="inbound.settings.addPortMap('', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.settings.portMap.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(pm, idx) in inbound.settings.portMap" :key="`pm-${idx}`" compact class="mb-8">
|
|
<a-input :style="{ width: '30%' }" v-model:value="pm.name" placeholder="5555">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '60%' }" v-model:value="pm.value" placeholder="1.1.1.1:7777" />
|
|
<a-button @click="inbound.settings.removePortMap(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
<a-form-item label="Follow redirect">
|
|
<a-switch v-model:checked="inbound.settings.followRedirect" />
|
|
</a-form-item>
|
|
</a-form>
|
|
|
|
<!-- TUN -->
|
|
<a-form v-if="protocol === Protocols.TUN" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item label="Interface name">
|
|
<a-input v-model:value="inbound.settings.name" placeholder="xray0" />
|
|
</a-form-item>
|
|
<a-form-item label="MTU">
|
|
<a-input-number v-model:value="inbound.settings.mtu" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="Gateway">
|
|
<a-button size="small" @click="inbound.settings.gateway.push('')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
<a-input v-for="(_ip, j) in inbound.settings.gateway" :key="`tun-gw-${j}`"
|
|
v-model:value="inbound.settings.gateway[j]" class="mt-4"
|
|
:placeholder="j === 0 ? '10.0.0.1/16' : 'fc00::1/64'">
|
|
<template #addonAfter>
|
|
<a-button size="small" @click="inbound.settings.gateway.splice(j, 1)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
</a-input>
|
|
</a-form-item>
|
|
<a-form-item label="DNS">
|
|
<a-button size="small" @click="inbound.settings.dns.push('')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
<a-input v-for="(_ip, j) in inbound.settings.dns" :key="`tun-dns-${j}`"
|
|
v-model:value="inbound.settings.dns[j]" class="mt-4" :placeholder="j === 0 ? '1.1.1.1' : '8.8.8.8'">
|
|
<template #addonAfter>
|
|
<a-button size="small" @click="inbound.settings.dns.splice(j, 1)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
</a-input>
|
|
</a-form-item>
|
|
<a-form-item label="User level">
|
|
<a-input-number v-model:value="inbound.settings.userLevel" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip
|
|
title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
|
|
Auto system routes
|
|
</a-tooltip>
|
|
</template>
|
|
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.push('')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
<a-input v-for="(_ip, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-rt-${j}`"
|
|
v-model:value="inbound.settings.autoSystemRoutingTable[j]" class="mt-4"
|
|
:placeholder="j === 0 ? '0.0.0.0/0' : '::/0'">
|
|
<template #addonAfter>
|
|
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.splice(j, 1)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
</a-input>
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip
|
|
title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
|
|
Auto outbounds interface
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value="inbound.settings.autoOutboundsInterface" placeholder="auto" />
|
|
</a-form-item>
|
|
</a-form>
|
|
|
|
<!-- WireGuard -->
|
|
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
|
<a-form-item>
|
|
<template #label>
|
|
Secret key
|
|
<SyncOutlined class="random-icon" @click="regenInboundWg" />
|
|
</template>
|
|
<a-input v-model:value="inbound.settings.secretKey" />
|
|
</a-form-item>
|
|
<a-form-item label="Public key">
|
|
<a-input v-model:value="inbound.settings.pubKey" disabled />
|
|
</a-form-item>
|
|
<a-form-item label="MTU">
|
|
<a-input-number v-model:value="inbound.settings.mtu" />
|
|
</a-form-item>
|
|
<a-form-item label="No-kernel TUN">
|
|
<a-switch v-model:checked="inbound.settings.noKernelTun" />
|
|
</a-form-item>
|
|
<a-form-item label="Peers">
|
|
<a-button size="small" @click="inbound.settings.addPeer()">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
Add peer
|
|
</a-button>
|
|
</a-form-item>
|
|
<div v-for="(peer, idx) in inbound.settings.peers" :key="idx" class="wg-peer">
|
|
<a-divider style="margin: 8px 0">
|
|
Peer {{ idx + 1 }}
|
|
<DeleteOutlined v-if="inbound.settings.peers.length > 1" class="danger-icon"
|
|
@click="inbound.settings.delPeer(idx)" />
|
|
</a-divider>
|
|
<a-form-item>
|
|
<template #label>
|
|
Secret key
|
|
<SyncOutlined class="random-icon" @click="regenWgKeypair(peer)" />
|
|
</template>
|
|
<a-input v-model:value="peer.privateKey" />
|
|
</a-form-item>
|
|
<a-form-item label="Public key">
|
|
<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">
|
|
<a-button size="small" @click="peer.allowedIPs.push('')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
<a-input v-for="(_ip, j) in peer.allowedIPs" :key="j" v-model:value="peer.allowedIPs[j]" class="mt-4">
|
|
<template #addonAfter>
|
|
<a-button v-if="peer.allowedIPs.length > 1" size="small" @click="peer.allowedIPs.splice(j, 1)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
</a-input>
|
|
</a-form-item>
|
|
<a-form-item label="Keep-alive">
|
|
<a-input-number v-model:value="peer.keepAlive" :min="0" />
|
|
</a-form-item>
|
|
</div>
|
|
</a-form>
|
|
|
|
<!-- ============== Fallbacks (VLESS/Trojan over TCP) ============== -->
|
|
<template v-if="showFallbacks">
|
|
<a-divider style="margin: 12px 0" />
|
|
<div class="fallbacks-header">
|
|
<a-tooltip
|
|
title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport.">
|
|
<span class="fallbacks-title">
|
|
Fallbacks ({{ inbound.settings.fallbacks.length }})
|
|
</span>
|
|
</a-tooltip>
|
|
<a-button type="primary" size="small" @click="addFallback">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
Add
|
|
</a-button>
|
|
</div>
|
|
|
|
<a-form v-for="(fallback, idx) in inbound.settings.fallbacks" :key="idx" :colon="false"
|
|
:label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
<a-divider style="margin: 0">
|
|
Fallback {{ idx + 1 }}
|
|
<DeleteOutlined class="danger-icon" @click="delFallback(idx)" />
|
|
</a-divider>
|
|
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip title="Match TLS SNI (server name). Leave empty to match any SNI.">
|
|
SNI
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value.trim="fallback.name" placeholder="any (leave empty)" />
|
|
</a-form-item>
|
|
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip
|
|
title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both.">
|
|
ALPN
|
|
</a-tooltip>
|
|
</template>
|
|
<a-select v-model:value="fallback.alpn">
|
|
<a-select-option value="">any</a-select-option>
|
|
<a-select-option value="h2">h2</a-select-option>
|
|
<a-select-option value="http/1.1">http/1.1</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<a-form-item :validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
|
|
:help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''">
|
|
<template #label>
|
|
<a-tooltip
|
|
title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any.">
|
|
Path
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value.trim="fallback.path" placeholder="any (leave empty) or /ws" />
|
|
</a-form-item>
|
|
|
|
<a-form-item :validate-status="!fallback.dest ? 'error' : ''"
|
|
:help="!fallback.dest ? 'Destination is required' : ''">
|
|
<template #label>
|
|
<a-tooltip
|
|
title="Where matching traffic is forwarded. Accepts a port number (80), an addr:port (127.0.0.1:8080), or a Unix socket path (/dev/shm/x.sock or @abstract).">
|
|
Destination
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input v-model:value.trim="fallback.dest" placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock" />
|
|
</a-form-item>
|
|
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip
|
|
title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it.">
|
|
PROXY
|
|
</a-tooltip>
|
|
</template>
|
|
<a-select v-model:value="fallback.xver">
|
|
<a-select-option :value="0">Off</a-select-option>
|
|
<a-select-option :value="1">v1</a-select-option>
|
|
<a-select-option :value="2">v2</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</a-form>
|
|
</template>
|
|
</a-tab-pane>
|
|
|
|
<!-- ============================== STREAM ============================== -->
|
|
<a-tab-pane v-if="canEnableStream" key="stream"
|
|
tab="Stream"><!-- "Stream" stays literal — it's a wire-format identifier -->
|
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
<a-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
|
|
<a-select v-model:value="network" :style="{ width: '75%' }">
|
|
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
|
<a-select-option value="kcp">mKCP</a-select-option>
|
|
<a-select-option value="ws">WebSocket</a-select-option>
|
|
<a-select-option value="grpc">gRPC</a-select-option>
|
|
<a-select-option value="httpupgrade">HTTPUpgrade</a-select-option>
|
|
<a-select-option value="xhttp">XHTTP</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<!-- TCP (RAW) — proxy-protocol + optional HTTP camouflage with full request/response editor -->
|
|
<template v-if="network === 'tcp'">
|
|
<a-form-item v-if="canEnableTls" label="Proxy Protocol">
|
|
<a-switch v-model:checked="inbound.stream.tcp.acceptProxyProtocol" />
|
|
</a-form-item>
|
|
<a-form-item :label="`HTTP ${t('camouflage')}`">
|
|
<a-switch :checked="inbound.stream.tcp.type === 'http'"
|
|
@change="(v) => (inbound.stream.tcp.type = v ? 'http' : 'none')" />
|
|
</a-form-item>
|
|
|
|
<template v-if="inbound.stream.tcp.type === 'http'">
|
|
<!-- Request -->
|
|
<a-divider :style="{ margin: '0' }">{{ t('pages.inbounds.stream.general.request') }}</a-divider>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.version')">
|
|
<a-input v-model:value="inbound.stream.tcp.request.version" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.method')">
|
|
<a-input v-model:value="inbound.stream.tcp.request.method" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
{{ t('pages.inbounds.stream.tcp.path') }}
|
|
<a-button size="small" :style="{ marginLeft: '6px' }"
|
|
@click="inbound.stream.tcp.request.addPath('/')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
<template v-for="(_p, idx) in inbound.stream.tcp.request.path" :key="`tcp-path-${idx}`">
|
|
<a-input v-model:value="inbound.stream.tcp.request.path[idx]" class="mb-4">
|
|
<template #addonAfter>
|
|
<a-button v-if="inbound.stream.tcp.request.path.length > 1" size="small"
|
|
@click="inbound.stream.tcp.request.removePath(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</template>
|
|
</a-input>
|
|
</template>
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
|
|
<a-button size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.tcp.request.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.tcp.request.headers" :key="`tcp-rh-${idx}`" compact
|
|
class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
|
:placeholder="t('pages.inbounds.stream.general.name')">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value"
|
|
:placeholder="t('pages.inbounds.stream.general.value')" />
|
|
<a-button @click="inbound.stream.tcp.request.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
|
|
<!-- Response -->
|
|
<a-divider :style="{ margin: '0' }">{{ t('pages.inbounds.stream.general.response') }}</a-divider>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.version')">
|
|
<a-input v-model:value="inbound.stream.tcp.response.version" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.status')">
|
|
<a-input v-model:value="inbound.stream.tcp.response.status" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.statusDescription')">
|
|
<a-input v-model:value="inbound.stream.tcp.response.reason" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.responseHeader')">
|
|
<a-button size="small"
|
|
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.tcp.response.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.tcp.response.headers" :key="`tcp-rsh-${idx}`" compact
|
|
class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
|
:placeholder="t('pages.inbounds.stream.general.name')">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value"
|
|
:placeholder="t('pages.inbounds.stream.general.value')" />
|
|
<a-button @click="inbound.stream.tcp.response.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- mKCP -->
|
|
<template v-if="network === 'kcp'">
|
|
<a-form-item label="MTU">
|
|
<a-input-number v-model:value="inbound.stream.kcp.mtu" :min="576" :max="1460" />
|
|
</a-form-item>
|
|
<a-form-item label="TTI (ms)">
|
|
<a-input-number v-model:value="inbound.stream.kcp.tti" :min="10" :max="100" />
|
|
</a-form-item>
|
|
<a-form-item label="Uplink (MB/s)">
|
|
<a-input-number v-model:value="inbound.stream.kcp.upCap" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="Downlink (MB/s)">
|
|
<a-input-number v-model:value="inbound.stream.kcp.downCap" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="CWND Multiplier">
|
|
<a-input-number v-model:value="inbound.stream.kcp.cwndMultiplier" :min="1" />
|
|
</a-form-item>
|
|
<a-form-item label="Max Sending Window">
|
|
<a-input-number v-model:value="inbound.stream.kcp.maxSendingWindow" :min="0" />
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- WebSocket -->
|
|
<template v-if="network === 'ws'">
|
|
<a-form-item label="Proxy Protocol">
|
|
<a-switch v-model:checked="inbound.stream.ws.acceptProxyProtocol" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('host')">
|
|
<a-input v-model:value="inbound.stream.ws.host" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('path')">
|
|
<a-input v-model:value="inbound.stream.ws.path" />
|
|
</a-form-item>
|
|
<a-form-item label="Heartbeat Period">
|
|
<a-input-number v-model:value="inbound.stream.ws.heartbeatPeriod" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
|
|
<a-button size="small" @click="inbound.stream.ws.addHeader('', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.ws.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.ws.headers" :key="`ws-h-${idx}`" compact class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
|
:placeholder="t('pages.inbounds.stream.general.name')">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value"
|
|
:placeholder="t('pages.inbounds.stream.general.value')" />
|
|
<a-button @click="inbound.stream.ws.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- gRPC -->
|
|
<template v-if="network === 'grpc'">
|
|
<a-form-item label="Service Name">
|
|
<a-input v-model:value="inbound.stream.grpc.serviceName" />
|
|
</a-form-item>
|
|
<a-form-item label="Authority">
|
|
<a-input v-model:value="inbound.stream.grpc.authority" />
|
|
</a-form-item>
|
|
<a-form-item label="Multi Mode">
|
|
<a-switch v-model:checked="inbound.stream.grpc.multiMode" />
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- HTTPUpgrade -->
|
|
<template v-if="network === 'httpupgrade'">
|
|
<a-form-item label="Proxy Protocol">
|
|
<a-switch v-model:checked="inbound.stream.httpupgrade.acceptProxyProtocol" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('host')">
|
|
<a-input v-model:value="inbound.stream.httpupgrade.host" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('path')">
|
|
<a-input v-model:value="inbound.stream.httpupgrade.path" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
|
|
<a-button size="small" @click="inbound.stream.httpupgrade.addHeader('', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.httpupgrade.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.httpupgrade.headers" :key="`hu-h-${idx}`" compact
|
|
class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
|
:placeholder="t('pages.inbounds.stream.general.name')">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value"
|
|
:placeholder="t('pages.inbounds.stream.general.value')" />
|
|
<a-button @click="inbound.stream.httpupgrade.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- XHTTP -->
|
|
<template v-if="network === 'xhttp'">
|
|
<a-form-item :label="t('host')">
|
|
<a-input v-model:value="inbound.stream.xhttp.host" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('path')">
|
|
<a-input v-model:value="inbound.stream.xhttp.path" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
|
|
<a-button size="small" @click="inbound.stream.xhttp.addHeader('', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.xhttp.headers" :key="`xh-h-${idx}`" compact class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
|
:placeholder="t('pages.inbounds.stream.general.name')">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value"
|
|
:placeholder="t('pages.inbounds.stream.general.value')" />
|
|
<a-button @click="inbound.stream.xhttp.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
<a-form-item label="Mode">
|
|
<a-select v-model:value="inbound.stream.xhttp.mode" :style="{ width: '50%' }">
|
|
<a-select-option v-for="m in MODE_OPTIONS" :key="m" :value="m">{{ m }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Max Buffered Upload">
|
|
<a-input-number v-model:value="inbound.stream.xhttp.scMaxBufferedPosts" />
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Max Upload Size (Byte)">
|
|
<a-input v-model:value="inbound.stream.xhttp.scMaxEachPostBytes" />
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.mode === 'stream-up'" label="Stream-Up Server">
|
|
<a-input v-model:value="inbound.stream.xhttp.scStreamUpServerSecs" />
|
|
</a-form-item>
|
|
<a-form-item label="Server Max Header Bytes">
|
|
<a-input-number v-model:value="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
|
|
placeholder="0 (default)" />
|
|
</a-form-item>
|
|
<a-form-item label="Padding Bytes">
|
|
<a-input v-model:value="inbound.stream.xhttp.xPaddingBytes" />
|
|
</a-form-item>
|
|
<a-form-item label="Padding Obfs Mode">
|
|
<a-switch v-model:checked="inbound.stream.xhttp.xPaddingObfsMode" />
|
|
</a-form-item>
|
|
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
|
<a-form-item label="Padding Key">
|
|
<a-input v-model:value="inbound.stream.xhttp.xPaddingKey" placeholder="x_padding" />
|
|
</a-form-item>
|
|
<a-form-item label="Padding Header">
|
|
<a-input v-model:value="inbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding" />
|
|
</a-form-item>
|
|
<a-form-item label="Padding Placement">
|
|
<a-select v-model:value="inbound.stream.xhttp.xPaddingPlacement">
|
|
<a-select-option value="">Default (queryInHeader)</a-select-option>
|
|
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
|
|
<a-select-option value="header">header</a-select-option>
|
|
<a-select-option value="cookie">cookie</a-select-option>
|
|
<a-select-option value="query">query</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="Padding Method">
|
|
<a-select v-model:value="inbound.stream.xhttp.xPaddingMethod">
|
|
<a-select-option value="">Default (repeat-x)</a-select-option>
|
|
<a-select-option value="repeat-x">repeat-x</a-select-option>
|
|
<a-select-option value="tokenish">tokenish</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</template>
|
|
<a-form-item label="Session Placement">
|
|
<a-select v-model:value="inbound.stream.xhttp.sessionPlacement">
|
|
<a-select-option value="">Default (path)</a-select-option>
|
|
<a-select-option value="path">path</a-select-option>
|
|
<a-select-option value="header">header</a-select-option>
|
|
<a-select-option value="cookie">cookie</a-select-option>
|
|
<a-select-option value="query">query</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item
|
|
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
|
|
label="Session Key">
|
|
<a-input v-model:value="inbound.stream.xhttp.sessionKey" placeholder="x_session" />
|
|
</a-form-item>
|
|
<a-form-item label="Sequence Placement">
|
|
<a-select v-model:value="inbound.stream.xhttp.seqPlacement">
|
|
<a-select-option value="">Default (path)</a-select-option>
|
|
<a-select-option value="path">path</a-select-option>
|
|
<a-select-option value="header">header</a-select-option>
|
|
<a-select-option value="cookie">cookie</a-select-option>
|
|
<a-select-option value="query">query</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
|
|
label="Sequence Key">
|
|
<a-input v-model:value="inbound.stream.xhttp.seqKey" placeholder="x_seq" />
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
|
|
<a-select v-model:value="inbound.stream.xhttp.uplinkDataPlacement">
|
|
<a-select-option value="">Default (body)</a-select-option>
|
|
<a-select-option value="body">body</a-select-option>
|
|
<a-select-option value="header">header</a-select-option>
|
|
<a-select-option value="cookie">cookie</a-select-option>
|
|
<a-select-option value="query">query</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item
|
|
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
|
label="Uplink Data Key">
|
|
<a-input v-model:value="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
|
|
</a-form-item>
|
|
<a-form-item label="No SSE Header">
|
|
<a-switch v-model:checked="inbound.stream.xhttp.noSSEHeader" />
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- ====== Security section ====== -->
|
|
<a-form-item label="Security">
|
|
<a-select v-model:value="security" :style="{ width: '160px' }" :disabled="!canEnableTls">
|
|
<a-select-option value="none">none</a-select-option>
|
|
<a-select-option value="tls">tls</a-select-option>
|
|
<a-select-option v-if="canEnableReality" value="reality">reality</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<template v-if="security === 'tls' && inbound.stream.tls">
|
|
<a-form-item label="SNI">
|
|
<a-input v-model:value="inbound.stream.tls.sni" placeholder="Server Name Indication" />
|
|
</a-form-item>
|
|
<a-form-item label="Cipher Suites">
|
|
<a-select v-model:value="inbound.stream.tls.cipherSuites">
|
|
<a-select-option value="">Auto</a-select-option>
|
|
<a-select-option v-for="[label, val] in CIPHER_SUITES" :key="val" :value="val">{{ label
|
|
}}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="Min/Max Version">
|
|
<a-input-group compact>
|
|
<a-select v-model:value="inbound.stream.tls.minVersion" :style="{ width: '50%' }">
|
|
<a-select-option v-for="v in TLS_VERSIONS" :key="v" :value="v">{{ v }}</a-select-option>
|
|
</a-select>
|
|
<a-select v-model:value="inbound.stream.tls.maxVersion" :style="{ width: '50%' }">
|
|
<a-select-option v-for="v in TLS_VERSIONS" :key="v" :value="v">{{ v }}</a-select-option>
|
|
</a-select>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
<a-form-item label="uTLS">
|
|
<a-select v-model:value="inbound.stream.tls.settings.fingerprint" :style="{ width: '100%' }">
|
|
<a-select-option value="">None</a-select-option>
|
|
<a-select-option v-for="fp in FINGERPRINTS" :key="fp" :value="fp">{{ fp }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="ALPN">
|
|
<a-select v-model:value="inbound.stream.tls.alpn" mode="multiple" :style="{ width: '100%' }"
|
|
:token-separators="[',']">
|
|
<a-select-option v-for="a in ALPNS" :key="a" :value="a">{{ a }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="Reject Unknown SNI">
|
|
<a-switch v-model:checked="inbound.stream.tls.rejectUnknownSni" />
|
|
</a-form-item>
|
|
<a-form-item label="Disable System Root">
|
|
<a-switch v-model:checked="inbound.stream.tls.disableSystemRoot" />
|
|
</a-form-item>
|
|
<a-form-item label="Session Resumption">
|
|
<a-switch v-model:checked="inbound.stream.tls.enableSessionResumption" />
|
|
</a-form-item>
|
|
|
|
|
|
<!-- Cert array — file path or inline content per row -->
|
|
<template v-for="(cert, idx) in inbound.stream.tls.certs" :key="`cert-${idx}`">
|
|
<a-form-item :label="t('certificate')">
|
|
<a-radio-group v-model:value="cert.useFile" button-style="solid">
|
|
<a-radio-button :value="true">{{ t('pages.inbounds.certificatePath') }}</a-radio-button>
|
|
<a-radio-button :value="false">{{ t('pages.inbounds.certificateContent') }}</a-radio-button>
|
|
</a-radio-group>
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-space>
|
|
<a-button v-if="idx === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
<a-button v-if="inbound.stream.tls.certs.length > 1" type="primary" size="small"
|
|
@click="inbound.stream.tls.removeCert(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-space>
|
|
</a-form-item>
|
|
<template v-if="cert.useFile">
|
|
<a-form-item :label="t('pages.inbounds.publicKey')">
|
|
<a-input v-model:value="cert.certFile" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.privatekey')">
|
|
<a-input v-model:value="cert.keyFile" />
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-button type="primary" :disabled="!defaultCert && !defaultKey" @click="setDefaultCertData(idx)">
|
|
{{ t('pages.inbounds.setDefaultCert') }}
|
|
</a-button>
|
|
</a-form-item>
|
|
</template>
|
|
<template v-else>
|
|
<a-form-item :label="t('pages.inbounds.publicKey')">
|
|
<a-textarea v-model:value="cert.cert" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.privatekey')">
|
|
<a-textarea v-model:value="cert.key" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
|
</a-form-item>
|
|
</template>
|
|
<a-form-item label="One Time Loading">
|
|
<a-switch v-model:checked="cert.oneTimeLoading" />
|
|
</a-form-item>
|
|
<a-form-item label="Usage Option">
|
|
<a-select v-model:value="cert.usage" :style="{ width: '50%' }">
|
|
<a-select-option v-for="u in USAGES" :key="u" :value="u">{{ u }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item v-if="cert.usage === 'issue'" label="Build Chain">
|
|
<a-switch v-model:checked="cert.buildChain" />
|
|
</a-form-item>
|
|
</template>
|
|
|
|
|
|
<!-- ECH (Encrypted Client Hello) -->
|
|
<a-form-item label="ECH key">
|
|
<a-input v-model:value="inbound.stream.tls.echServerKeys" />
|
|
</a-form-item>
|
|
<a-form-item label="ECH config">
|
|
<a-input v-model:value="inbound.stream.tls.settings.echConfigList" />
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-space>
|
|
<a-button type="primary" :loading="saving" @click="getNewEchCert">Get New ECH Cert</a-button>
|
|
<a-button danger @click="clearEchCert">Clear</a-button>
|
|
</a-space>
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<template v-if="security === 'reality' && inbound.stream.reality">
|
|
<a-form-item label="Show">
|
|
<a-switch v-model:checked="inbound.stream.reality.show" />
|
|
</a-form-item>
|
|
<a-form-item label="Xver">
|
|
<a-input-number v-model:value="inbound.stream.reality.xver" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="uTLS">
|
|
<a-select v-model:value="inbound.stream.reality.settings.fingerprint" :style="{ width: '100%' }">
|
|
<a-select-option v-for="fp in FINGERPRINTS" :key="fp" :value="fp">{{ fp }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
Target
|
|
<SyncOutlined class="random-icon" @click="randomizeRealityTarget" />
|
|
</template>
|
|
<a-input v-model:value="inbound.stream.reality.target" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
SNI
|
|
<SyncOutlined class="random-icon" @click="randomizeRealityTarget" />
|
|
</template>
|
|
<a-input v-model:value="inbound.stream.reality.serverNames" />
|
|
</a-form-item>
|
|
<a-form-item label="Max Time Diff (ms)">
|
|
<a-input-number v-model:value="inbound.stream.reality.maxTimediff" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="Min Client Ver">
|
|
<a-input v-model:value="inbound.stream.reality.minClientVer" placeholder="25.9.11" />
|
|
</a-form-item>
|
|
<a-form-item label="Max Client Ver">
|
|
<a-input v-model:value="inbound.stream.reality.maxClientVer" placeholder="25.9.11" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
Short IDs
|
|
<SyncOutlined class="random-icon" @click="randomizeShortIds" />
|
|
</template>
|
|
<a-textarea v-model:value="inbound.stream.reality.shortIds" :auto-size="{ minRows: 1, maxRows: 4 }" />
|
|
</a-form-item>
|
|
<a-form-item label="SpiderX">
|
|
<a-input v-model:value="inbound.stream.reality.settings.spiderX" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.publicKey')">
|
|
<a-textarea v-model:value="inbound.stream.reality.settings.publicKey"
|
|
:auto-size="{ minRows: 1, maxRows: 4 }" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('pages.inbounds.privatekey')">
|
|
<a-textarea v-model:value="inbound.stream.reality.privateKey" :auto-size="{ minRows: 1, maxRows: 4 }" />
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-space>
|
|
<a-button type="primary" :loading="saving" @click="genRealityKeypair">Get New Cert</a-button>
|
|
<a-button danger @click="clearRealityKeypair">Clear</a-button>
|
|
</a-space>
|
|
</a-form-item>
|
|
<a-form-item label="mldsa65 Seed">
|
|
<a-textarea v-model:value="inbound.stream.reality.mldsa65Seed" :auto-size="{ minRows: 2, maxRows: 6 }" />
|
|
</a-form-item>
|
|
<a-form-item label="mldsa65 Verify">
|
|
<a-textarea v-model:value="inbound.stream.reality.settings.mldsa65Verify"
|
|
:auto-size="{ minRows: 2, maxRows: 6 }" />
|
|
</a-form-item>
|
|
<a-form-item label=" ">
|
|
<a-space>
|
|
<a-button type="primary" :loading="saving" @click="genMldsa65">Get New Seed</a-button>
|
|
<a-button danger @click="clearMldsa65">Clear</a-button>
|
|
</a-space>
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- ====== External Proxy ====== -->
|
|
<a-form-item label="External Proxy">
|
|
<a-switch v-model:checked="externalProxy" />
|
|
<a-button v-if="externalProxy" size="small" type="primary" :style="{ marginLeft: '10px' }"
|
|
@click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' })">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="externalProxy" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(row, idx) in inbound.stream.externalProxy" :key="`ep-${idx}`" compact
|
|
:style="{ margin: '8px 0' }">
|
|
<a-tooltip title="Force TLS">
|
|
<a-select v-model:value="row.forceTls" :style="{ width: '20%' }">
|
|
<a-select-option value="same">{{ t('pages.inbounds.same') }}</a-select-option>
|
|
<a-select-option value="none">{{ t('none') }}</a-select-option>
|
|
<a-select-option value="tls">TLS</a-select-option>
|
|
</a-select>
|
|
</a-tooltip>
|
|
<a-input v-model:value="row.dest" :style="{ width: '30%' }" :placeholder="t('host')" />
|
|
<a-tooltip :title="t('pages.inbounds.port')">
|
|
<a-input-number v-model:value="row.port" :style="{ width: '15%' }" :min="1" :max="65535" />
|
|
</a-tooltip>
|
|
<a-input v-model:value="row.remark" :style="{ width: '35%' }" :placeholder="t('pages.inbounds.remark')">
|
|
<template #addonAfter>
|
|
<MinusOutlined @click="inbound.stream.externalProxy.splice(idx, 1)" />
|
|
</template>
|
|
</a-input>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
|
|
<!-- ====== Sockopt ====== -->
|
|
<a-form-item label="Sockopt">
|
|
<a-switch v-model:checked="inbound.stream.sockoptSwitch" />
|
|
</a-form-item>
|
|
<template v-if="inbound.stream.sockoptSwitch && inbound.stream.sockopt">
|
|
<a-form-item label="Route Mark">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.mark" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP Keep Alive Interval">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP Keep Alive Idle">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP Max Seg">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.tcpMaxSeg" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP User Timeout">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.tcpUserTimeout" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP Window Clamp">
|
|
<a-input-number v-model:value="inbound.stream.sockopt.tcpWindowClamp" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="Proxy Protocol">
|
|
<a-switch v-model:checked="inbound.stream.sockopt.acceptProxyProtocol" />
|
|
</a-form-item>
|
|
<a-form-item label="TCP Fast Open">
|
|
<a-switch v-model:checked="inbound.stream.sockopt.tcpFastOpen" />
|
|
</a-form-item>
|
|
<a-form-item label="Multipath TCP">
|
|
<a-switch v-model:checked="inbound.stream.sockopt.tcpMptcp" />
|
|
</a-form-item>
|
|
<a-form-item label="Penetrate">
|
|
<a-switch v-model:checked="inbound.stream.sockopt.penetrate" />
|
|
</a-form-item>
|
|
<a-form-item label="V6 Only">
|
|
<a-switch v-model:checked="inbound.stream.sockopt.V6Only" />
|
|
</a-form-item>
|
|
<a-form-item label="Domain Strategy">
|
|
<a-select v-model:value="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }">
|
|
<a-select-option v-for="d in DOMAIN_STRATEGIES" :key="d" :value="d">{{ d }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="TCP Congestion">
|
|
<a-select v-model:value="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }">
|
|
<a-select-option v-for="c in TCP_CONGESTIONS" :key="c" :value="c">{{ c }}</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="TProxy">
|
|
<a-select v-model:value="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }">
|
|
<a-select-option value="off">Off</a-select-option>
|
|
<a-select-option value="redirect">Redirect</a-select-option>
|
|
<a-select-option value="tproxy">TProxy</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="Dialer Proxy">
|
|
<a-input v-model:value="inbound.stream.sockopt.dialerProxy" />
|
|
</a-form-item>
|
|
<a-form-item label="Interface Name">
|
|
<a-input v-model:value="inbound.stream.sockopt.interfaceName" />
|
|
</a-form-item>
|
|
<a-form-item label="Trusted X-Forwarded-For">
|
|
<a-select v-model:value="inbound.stream.sockopt.trustedXForwardedFor" mode="tags"
|
|
:style="{ width: '100%' }" :token-separators="[',']">
|
|
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
|
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
|
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
|
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- ====== Hysteria stream settings ====== -->
|
|
<!-- Per https://xtls.github.io/config/transports/hysteria.html -->
|
|
<template v-if="protocol === Protocols.HYSTERIA">
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip title="Hysteria protocol version. Currently must be 2.">
|
|
Version
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<template #label>
|
|
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
|
|
UDP idle timeout
|
|
</a-tooltip>
|
|
</template>
|
|
<a-input-number v-model:value="inbound.stream.hysteria.udpIdleTimeout" :min="0" />
|
|
</a-form-item>
|
|
<a-form-item label="Masquerade">
|
|
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
|
|
</a-form-item>
|
|
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
|
|
<a-form-item label="Type">
|
|
<a-select v-model:value="inbound.stream.hysteria.masquerade.type" :style="{ width: '50%' }">
|
|
<a-select-option value="proxy">Proxy</a-select-option>
|
|
<a-select-option value="file">File</a-select-option>
|
|
<a-select-option value="string">String</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
|
|
<!-- Proxy type: url / rewriteHost / insecure -->
|
|
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
|
|
<a-form-item label="URL">
|
|
<a-input v-model:value="inbound.stream.hysteria.masquerade.url" placeholder="https://example.com" />
|
|
</a-form-item>
|
|
<a-form-item label="Rewrite Host">
|
|
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.rewriteHost" />
|
|
</a-form-item>
|
|
<a-form-item label="Insecure">
|
|
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.insecure" />
|
|
</a-form-item>
|
|
</template>
|
|
|
|
<!-- File type: dir -->
|
|
<a-form-item v-if="inbound.stream.hysteria.masquerade.type === 'file'" label="Directory">
|
|
<a-input v-model:value="inbound.stream.hysteria.masquerade.dir" placeholder="/path/to/www" />
|
|
</a-form-item>
|
|
|
|
<!-- String type: content / statusCode / headers -->
|
|
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
|
|
<a-form-item label="Content">
|
|
<a-textarea v-model:value="inbound.stream.hysteria.masquerade.content"
|
|
:auto-size="{ minRows: 2, maxRows: 6 }" />
|
|
</a-form-item>
|
|
<a-form-item label="Status Code">
|
|
<a-input-number v-model:value="inbound.stream.hysteria.masquerade.statusCode" :min="100" :max="599"
|
|
placeholder="200" />
|
|
</a-form-item>
|
|
<a-form-item label="Headers">
|
|
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">
|
|
<template #icon>
|
|
<PlusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-form-item>
|
|
<a-form-item v-if="inbound.stream.hysteria.masquerade.headers.length > 0" :wrapper-col="{ span: 24 }">
|
|
<a-input-group v-for="(h, idx) in inbound.stream.hysteria.masquerade.headers" :key="`mh-${idx}`"
|
|
compact class="mb-8">
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.name" placeholder="Name">
|
|
<template #addonBefore>{{ idx + 1 }}</template>
|
|
</a-input>
|
|
<a-input :style="{ width: '45%' }" v-model:value="h.value" placeholder="Value" />
|
|
<a-button @click="inbound.stream.hysteria.masquerade.removeHeader(idx)">
|
|
<template #icon>
|
|
<MinusOutlined />
|
|
</template>
|
|
</a-button>
|
|
</a-input-group>
|
|
</a-form-item>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
</a-form>
|
|
|
|
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
|
|
<FinalMaskForm :stream="inbound.stream" :protocol="protocol" />
|
|
</a-tab-pane>
|
|
|
|
<!-- ============================== SNIFFING ============================== -->
|
|
<a-tab-pane key="sniffing" tab="Sniffing"><!-- "Sniffing" stays literal — xray config term -->
|
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
<a-form-item label="Enabled">
|
|
<a-switch v-model:checked="inbound.sniffing.enabled" />
|
|
</a-form-item>
|
|
<template v-if="inbound.sniffing.enabled">
|
|
<a-form-item :wrapper-col="{ span: 24 }">
|
|
<a-checkbox-group v-model:value="inbound.sniffing.destOverride">
|
|
<a-checkbox v-for="(value, key) in SNIFFING_OPTION" :key="key" :value="value">{{ key }}</a-checkbox>
|
|
</a-checkbox-group>
|
|
</a-form-item>
|
|
<a-form-item label="Metadata only">
|
|
<a-switch v-model:checked="inbound.sniffing.metadataOnly" />
|
|
</a-form-item>
|
|
<a-form-item label="Route only">
|
|
<a-switch v-model:checked="inbound.sniffing.routeOnly" />
|
|
</a-form-item>
|
|
<a-form-item label="IPs excluded">
|
|
<a-select v-model:value="inbound.sniffing.ipsExcluded" mode="tags" :token-separators="[',']"
|
|
placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
|
|
</a-form-item>
|
|
<a-form-item label="Domains excluded">
|
|
<a-select v-model:value="inbound.sniffing.domainsExcluded" mode="tags" :token-separators="[',']"
|
|
placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
|
|
</a-form-item>
|
|
</template>
|
|
</a-form>
|
|
</a-tab-pane>
|
|
|
|
<!-- ============================== ADVANCED ============================== -->
|
|
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
|
|
<div class="advanced-shell">
|
|
<div class="advanced-panel">
|
|
<div class="advanced-panel__header">
|
|
<div>
|
|
<div class="advanced-panel__title">Inbound JSON sections</div>
|
|
<div class="advanced-panel__subtitle">
|
|
Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
|
|
<a-tab-pane key="all" tab="All">
|
|
<div class="advanced-editor-meta">
|
|
Full inbound object with all fields in one editor.
|
|
</div>
|
|
<JsonEditor v-model:value="advancedAllConfig" min-height="340px" max-height="560px" />
|
|
</a-tab-pane>
|
|
<a-tab-pane key="settings" tab="Settings">
|
|
<div class="advanced-editor-meta">
|
|
Xray settings block wrapper:
|
|
<code>{ settings: { ... } }</code>.
|
|
</div>
|
|
<JsonEditor v-model:value="advancedSettingsConfig" min-height="320px" max-height="540px" />
|
|
</a-tab-pane>
|
|
<a-tab-pane key="sniffingSection" tab="Sniffing">
|
|
<div class="advanced-editor-meta">
|
|
Xray sniffing block wrapper:
|
|
<code>{ sniffing: { ... } }</code>.
|
|
</div>
|
|
<JsonEditor v-model:value="advancedSniffingConfig" min-height="240px" max-height="420px" />
|
|
</a-tab-pane>
|
|
<a-tab-pane v-if="canEnableStream" key="streamSection" tab="Stream">
|
|
<div class="advanced-editor-meta">
|
|
Xray stream block wrapper:
|
|
<code>{ streamSettings: { ... } }</code>.
|
|
</div>
|
|
<JsonEditor v-model:value="advancedStreamConfig" min-height="320px" max-height="540px" />
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</div>
|
|
</div>
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</a-modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mt-4 {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.mt-8 {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.mt-12 {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.mb-4 {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.mb-8 {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.mb-12 {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.random-icon {
|
|
margin-left: 4px;
|
|
cursor: pointer;
|
|
color: var(--ant-primary-color, #1890ff);
|
|
}
|
|
|
|
.danger-icon {
|
|
margin-left: 6px;
|
|
cursor: pointer;
|
|
color: #ff4d4f;
|
|
}
|
|
|
|
.vless-auth-state {
|
|
display: block;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.client-summary {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.client-summary th,
|
|
.client-summary td {
|
|
padding: 4px 8px;
|
|
text-align: left;
|
|
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
|
}
|
|
|
|
.fallbacks-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.fallbacks-title {
|
|
font-weight: 500;
|
|
flex: 1;
|
|
}
|
|
|
|
.wg-peer {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.advanced-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.advanced-panel {
|
|
padding: 14px;
|
|
border: 1px solid rgba(128, 128, 128, 0.18);
|
|
border-radius: 12px;
|
|
background: rgba(128, 128, 128, 0.04);
|
|
}
|
|
|
|
.advanced-panel__header {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.advanced-panel__title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.advanced-panel__subtitle {
|
|
margin-top: 4px;
|
|
opacity: 0.7;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.advanced-inner-tabs :deep(.ant-tabs-nav) {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.advanced-inner-tabs :deep(.ant-tabs-tab) {
|
|
padding-inline: 14px;
|
|
}
|
|
|
|
.advanced-editor-meta {
|
|
margin-bottom: 10px;
|
|
opacity: 0.75;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.advanced-panel {
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.advanced-inner-tabs :deep(.ant-tabs-tab) {
|
|
padding-inline: 10px;
|
|
}
|
|
}
|
|
|
|
:global(body.dark) .advanced-panel,
|
|
:global(html[data-theme='ultra-dark']) .advanced-panel {
|
|
border-color: rgba(255, 255, 255, 0.12);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.section-heading {
|
|
font-weight: 500;
|
|
margin: 12px 0 6px;
|
|
opacity: 0.85;
|
|
}
|
|
</style>
|