diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index 5f7cc561..4bb5e07d 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -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; diff --git a/frontend/src/pages/clients/ClientBulkAddModal.vue b/frontend/src/pages/clients/ClientBulkAddModal.vue index 616100fa..ad7d4fc8 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.vue +++ b/frontend/src/pages/clients/ClientBulkAddModal.vue @@ -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() { + + + none + {{ k }} + + + - + diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue index 344ef7b9..c33e1cee 100644 --- a/frontend/src/pages/clients/ClientFormModal.vue +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -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() { - + + + + + + + + + + none + {{ k }} + @@ -241,6 +308,19 @@ async function onSubmit() { {{ t('enable') }} + + + + {{ t('refresh') }} + + {{ t('clearAll') || 'Clear' }} + + + + {{ ip }} + + {{ t('tgbot.noIpRecord') || 'No IP record' }} + diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue index 909db9b4..bcf6b82e 100644 --- a/frontend/src/pages/clients/ClientsPage.vue +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -34,6 +34,7 @@ const { loading, fetched, subSettings, + ipLimitEnable, create, update, remove, @@ -516,11 +517,12 @@ const columns = computed(() => [ + :attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable" :save="onSave" /> - + diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js index a46df741..3dcea310 100644 --- a/frontend/src/pages/clients/useClients.js +++ b/frontend/src/pages/clients/useClients.js @@ -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, diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 27f6589a..d2c2182c 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -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')); - - - - - - - - - - - - - Email - - - - - - - - - - ID - - - - - - - - - {{ k }} - - - - - - - Password - - - - - - - - - - Auth password - - - - - - - - - none - {{ k }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Email - {{ protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS ? 'Password' : (protocol - === - Protocols.HYSTERIA ? 'Auth' : 'ID') }} - - - - - {{ c.email }} - {{ c.id || c.password || c.auth }} - - - - - - - + @@ -1772,205 +1618,6 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - - - - none - tls - reality - - - - - - - - - - Auto - {{ label - }} - - - - - - {{ v }} - - - {{ v }} - - - - - - None - {{ fp }} - - - - - {{ a }} - - - - - - - - - - - - - - - - - - {{ t('pages.inbounds.certificatePath') }} - {{ t('pages.inbounds.certificateContent') }} - - - - - - - - - - - - - - - - - - - - - - - - - - {{ t('pages.inbounds.setDefaultCert') }} - - - - - - - - - - - - - - - - - {{ u }} - - - - - - - - - - - - - - - - - - Get New ECH Cert - Clear - - - - - - - - - - - - - - {{ fp }} - - - - - Target - - - - - - - SNI - - - - - - - - - - - - - - - - Short IDs - - - - - - - - - - - - - - - - Get New Cert - Clear - - - - - - - - - - - Get New Seed - Clear - - - - @@ -2164,6 +1811,205 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); + + + + + + none + tls + reality + + + + + + + + + + Auto + {{ label + }} + + + + + + {{ v }} + + + {{ v }} + + + + + + None + {{ fp }} + + + + + {{ a }} + + + + + + + + + + + + + + + + {{ t('pages.inbounds.certificatePath') }} + {{ t('pages.inbounds.certificateContent') }} + + + + + + + + + + + + + + + + + + + + + + + + + + {{ t('pages.inbounds.setDefaultCert') }} + + + + + + + + + + + + + + + + + {{ u }} + + + + + + + + + + + + + + + + Get New ECH Cert + Clear + + + + + + + + + + + + + + {{ fp }} + + + + + Target + + + + + + + SNI + + + + + + + + + + + + + + + + Short IDs + + + + + + + + + + + + + + + + Get New Cert + Clear + + + + + + + + + + + Get New Seed + Clear + + + + + + @@ -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;