feat(inbounds,clients): clean up inbound modal + enrich client modal

Inbound modal rework (InboundFormModal.vue + inbound.js):
- Drop the embedded Client subform in the Protocol tab. Multi-inbound
  clients are managed exclusively from the Clients page now; a fresh
  inbound is created with zero clients (settings constructors default
  to []) and the user attaches clients afterwards.
- Hide the Protocol tab entirely when it has nothing to render
  (VMESS, Trojan without fallbacks, Hysteria). Auto-switches active
  tab to Basic when the tab disappears while focused.
- Move the Security section (Security selector + TLS block with certs
  and ECH + Reality block) out of the Stream tab into its own
  Security tab, sharing the canEnableStream gate.

Client modal additions (ClientFormModal.vue + ClientBulkAddModal.vue):
- Flow select (xtls-rprx-vision / -udp443) appears only when the
  panel actually has a Vision-capable inbound (VLESS or PortFallback
  on TCP with TLS or Reality). Hidden otherwise, and cleared when
  it disappears.
- IP Limit input is disabled when the panel-level ipLimitEnable
  setting is off, fetched into useClients alongside subSettings and
  threaded through ClientsPage to both modals.
- Edit modal now shows an "IP Log" section listing IPs that have
  connected with the client's credentials, with refresh and clear
  buttons (calls the renamed /panel/api/clients/ips and /clearIps
  endpoints).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 11:53:27 +02:00
parent a79cb9fe6d
commit fc8765917e
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 352 additions and 399 deletions

View file

@ -2570,7 +2570,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
Inbound.VmessSettings = class extends Inbound.Settings {
constructor(protocol,
vmesses = [new Inbound.VmessSettings.VMESS()]) {
vmesses = []) {
super(protocol);
this.vmesses = vmesses;
}
@ -2638,7 +2638,7 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
Inbound.VLESSSettings = class extends Inbound.Settings {
constructor(
protocol,
vlesses = [new Inbound.VLESSSettings.VLESS()],
vlesses = [],
decryption = "none",
encryption = "none",
fallbacks = [],
@ -2785,7 +2785,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
Inbound.TrojanSettings = class extends Inbound.Settings {
constructor(protocol,
trojans = [new Inbound.TrojanSettings.Trojan()],
trojans = [],
fallbacks = [],) {
super(protocol);
this.trojans = trojans;
@ -2868,7 +2868,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
method = SSMethods.BLAKE3_AES_256_GCM,
password = RandomUtil.randomShadowsocksPassword(),
network = 'tcp,udp',
shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()],
shadowsockses = [],
ivCheck = false,
) {
super(protocol);
@ -2930,7 +2930,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
};
Inbound.HysteriaSettings = class extends Inbound.Settings {
constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) {
constructor(protocol, version = 2, hysterias = []) {
super(protocol);
this.version = version;
this.hysterias = hysterias;

View file

@ -7,12 +7,17 @@ import { message } from 'ant-design-vue';
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
import DateTimePicker from '@/components/DateTimePicker.vue';
import { DBInbound } from '@/models/dbinbound.js';
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
inbounds: { type: Array, default: () => [] },
ipLimitEnable: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'saved']);
@ -31,12 +36,32 @@ const form = reactive({
quantity: 1,
subId: '',
comment: '',
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
inboundIds: [],
});
const flowCapableIds = computed(() => {
const ids = new Set();
for (const row of props.inbounds || []) {
try {
const parsed = new DBInbound(row).toInbound();
if (parsed.canEnableTlsFlow?.()) ids.add(row.id);
} catch (_e) { /* ignore */ }
}
return ids;
});
const showFlow = computed(() =>
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
);
watch(showFlow, (next) => {
if (!next) form.flow = '';
});
const expiryDate = computed({
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
@ -64,6 +89,7 @@ watch(() => props.open, (next) => {
form.quantity = 1;
form.subId = '';
form.comment = '';
form.flow = '';
form.limitIp = 0;
form.totalGB = 0;
form.expiryTime = 0;
@ -118,6 +144,7 @@ async function submit() {
id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow.value ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: form.expiryTime,
limitIp: Number(form.limitIp) || 0,
@ -191,8 +218,15 @@ async function submit() {
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item v-if="showFlow" label="Flow">
<a-select v-model:value="form.flow" :style="{ width: '220px' }">
<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 :label="t('pages.clients.limitIp') || 'IP Limit'">
<a-input-number v-model:value="form.limitIp" :min="0" />
<a-input-number v-model:value="form.limitIp" :min="0" :disabled="!ipLimitEnable" />
</a-form-item>
<a-form-item :label="t('pages.clients.totalGB') || 'Total (GB)'">

View file

@ -3,7 +3,11 @@ import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { RandomUtil } from '@/utils';
import { HttpUtil, RandomUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const props = defineProps({
open: { type: Boolean, default: false },
@ -11,6 +15,7 @@ const props = defineProps({
client: { type: Object, default: null },
inbounds: { type: Array, default: () => [] },
attachedIds: { type: Array, default: () => [] },
ipLimitEnable: { type: Boolean, default: false },
save: { type: Function, required: true },
});
@ -27,6 +32,7 @@ function emptyForm() {
uuid: '',
password: '',
auth: '',
flow: '',
totalGB: 0,
expiryTime: null,
limitIp: 0,
@ -49,12 +55,14 @@ watch(
form.uuid = props.client.uuid || '';
form.password = props.client.password || '';
form.auth = props.client.auth || '';
form.flow = props.client.flow || '';
form.totalGB = bytesToGB(props.client.totalGB || 0);
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
form.limitIp = props.client.limitIp || 0;
form.comment = props.client.comment || '';
form.enable = !!props.client.enable;
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
void loadIps();
} else {
form.uuid = RandomUtil.randomUUID();
form.subId = RandomUtil.randomLowerAndNum(16);
@ -82,6 +90,53 @@ const inboundOptions = computed(() =>
})),
);
const flowCapableIds = computed(() => {
const ids = new Set();
for (const row of props.inbounds || []) {
try {
const parsed = new DBInbound(row).toInbound();
if (parsed.canEnableTlsFlow?.()) ids.add(row.id);
} catch (_e) { /* ignore unparsable */ }
}
return ids;
});
const showFlow = computed(() =>
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
);
watch(showFlow, (next) => {
if (!next) form.flow = '';
});
const clientIps = ref([]);
const ipsLoading = ref(false);
const ipsClearing = ref(false);
async function loadIps() {
if (!isEdit.value || !props.client?.email) return;
ipsLoading.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
if (!msg?.success) { clientIps.value = []; return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
} finally {
ipsLoading.value = false;
}
}
async function clearIps() {
if (!isEdit.value || !props.client?.email) return;
ipsClearing.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
if (msg?.success) clientIps.value = [];
} finally {
ipsClearing.value = false;
}
}
function close() {
emit('update:open', false);
}
@ -121,6 +176,7 @@ async function onSubmit() {
id: form.uuid,
password: form.password,
auth: form.auth,
flow: showFlow.value ? (form.flow || '') : '',
totalGB: gbToBytes(form.totalGB),
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
limitIp: Number(form.limitIp) || 0,
@ -209,7 +265,18 @@ async function onSubmit() {
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.clients.limitIp') || 'IP limit'">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
<a-input-number v-model:value="form.limitIp" :min="0" :disabled="!ipLimitEnable" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row v-if="showFlow" :gutter="16">
<a-col :span="12">
<a-form-item label="Flow">
<a-select v-model:value="form.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-col>
</a-row>
@ -241,6 +308,19 @@ async function onSubmit() {
<a-switch v-model:checked="form.enable" />
<span style="margin-left: 8px">{{ t('enable') }}</span>
</a-form-item>
<a-form-item v-if="isEdit" :label="t('pages.inbounds.ipLog') || 'IP Log'">
<a-space style="margin-bottom: 8px">
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
{{ t('clearAll') || 'Clear' }}
</a-button>
</a-space>
<div v-if="clientIps.length > 0">
<a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
</div>
<a-tag v-else>{{ t('tgbot.noIpRecord') || 'No IP record' }}</a-tag>
</a-form-item>
</a-form>
</a-modal>
</template>

View file

@ -34,6 +34,7 @@ const {
loading,
fetched,
subSettings,
ipLimitEnable,
create,
update,
remove,
@ -516,11 +517,12 @@ const columns = computed(() => [
</a-layout>
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
:attached-ids="editingAttachedIds" :inbounds="inbounds" :save="onSave" />
:attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable" :save="onSave" />
<ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
:is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
<ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
<ClientBulkAddModal v-model:open="bulkAddOpen" :inbounds="inbounds" @saved="onBulkAddSaved" />
<ClientBulkAddModal v-model:open="bulkAddOpen" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
@saved="onBulkAddSaved" />
</a-layout>
</a-config-provider>
</template>

View file

@ -11,6 +11,7 @@ export function useClients() {
const loading = ref(false);
const fetched = ref(false);
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
const ipLimitEnable = ref(false);
let onlinesTimer = null;
async function refresh() {
@ -42,6 +43,7 @@ export function useClients() {
subJsonURI: s.subJsonURI || '',
subJsonEnable: !!s.subJsonEnable,
};
ipLimitEnable.value = !!s.ipLimitEnable;
}
async function refreshOnlines() {
@ -138,6 +140,7 @@ export function useClients() {
loading,
fetched,
subSettings,
ipLimitEnable,
refresh,
refreshOnlines,
create,

View file

@ -17,8 +17,6 @@ import {
Inbound,
Protocols,
SSMethods,
USERS_SECURITY,
TLS_FLOW_CONTROL,
SNIFFING_OPTION,
TLS_VERSION_OPTION,
TLS_CIPHER_OPTION,
@ -63,8 +61,6 @@ 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.
@ -123,35 +119,6 @@ const security = computed({
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.PORTFALLBACK:
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:
case Protocols.PORTFALLBACK: 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 isVlessLike = computed(() => {
if (!inbound.value) return false;
return inbound.value.protocol === Protocols.VLESS
@ -233,11 +200,9 @@ async function saveFallbackChildren(masterId) {
return !!msg?.success;
}
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.
@ -251,6 +216,23 @@ const showFallbacks = computed(() => {
return inbound.value.protocol === Protocols.TROJAN;
});
const hasProtocolTabContent = computed(() => {
if (!inbound.value) return false;
if (isVlessLike.value) return true;
if (showFallbacks.value) return true;
switch (inbound.value.protocol) {
case Protocols.SHADOWSOCKS:
case Protocols.HTTP:
case Protocols.MIXED:
case Protocols.TUNNEL:
case Protocols.TUN:
case Protocols.WIREGUARD:
return true;
default:
return false;
}
});
function addFallback() {
inbound.value?.settings?.addFallback?.();
}
@ -268,16 +250,6 @@ const totalGB = computed({
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
@ -371,6 +343,12 @@ watch(activeTabKey, (next, prev) => {
}
});
watch(hasProtocolTabContent, (next) => {
if (!next && activeTabKey.value === 'protocol') {
activeTabKey.value = 'basic';
}
});
// In add mode, switching protocol restamps settings + re-syncs port.
function onProtocolChange(next) {
if (props.mode === 'edit' || !inbound.value) return;
@ -573,25 +551,9 @@ const advancedStreamConfig = makeWrappedAdvancedConfig({
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;
@ -730,13 +692,9 @@ const selectedVlessAuth = computed(() => {
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);
@ -897,119 +855,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</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 || isVlessLike">
<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>
<a-tab-pane v-if="hasProtocolTabContent" key="protocol" :tab="t('pages.inbounds.protocol')">
<!-- VLess decryption / encryption -->
<a-form v-if="isVlessLike" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }"
class="mt-12">
@ -1772,205 +1618,6 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</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" />
@ -2164,6 +1811,205 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
<FinalMaskForm :stream="inbound.stream" :protocol="protocol" />
</a-tab-pane>
<!-- ============================== SECURITY ============================== -->
<a-tab-pane v-if="canEnableStream" key="security" tab="Security">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<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>
<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>
<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>
</a-form>
</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 } }">
@ -2285,18 +2131,6 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
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;