| <template> |
| <BaseDialog |
| :show="show" |
| :title="t('admin.tlsFingerprintProfiles.title')" |
| width="wide" |
| @close="$emit('close')" |
| > |
| <div class="space-y-4"> |
| |
| <div class="flex items-center justify-between"> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.description') }} |
| </p> |
| <button @click="showCreateModal = true" class="btn btn-primary btn-sm"> |
| <Icon name="plus" size="sm" class="mr-1" /> |
| {{ t('admin.tlsFingerprintProfiles.createProfile') }} |
| </button> |
| </div> |
| |
| |
| <div v-if="loading" class="flex items-center justify-center py-8"> |
| <Icon name="refresh" size="lg" class="animate-spin text-gray-400" /> |
| </div> |
| |
| <div v-else-if="profiles.length === 0" class="py-8 text-center"> |
| <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"> |
| <Icon name="shield" size="lg" class="text-gray-400" /> |
| </div> |
| <h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.tlsFingerprintProfiles.noProfiles') }} |
| </h4> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.createFirstProfile') }} |
| </p> |
| </div> |
| |
| <div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600"> |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> |
| <thead class="sticky top-0 bg-gray-50 dark:bg-dark-700"> |
| <tr> |
| <th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.columns.name') }} |
| </th> |
| <th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.columns.description') }} |
| </th> |
| <th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.columns.grease') }} |
| </th> |
| <th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.columns.alpn') }} |
| </th> |
| <th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.columns.actions') }} |
| </th> |
| </tr> |
| </thead> |
| <tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800"> |
| <tr v-for="profile in profiles" :key="profile.id" class="hover:bg-gray-50 dark:hover:bg-dark-700"> |
| <td class="px-3 py-2"> |
| <div class="font-medium text-gray-900 dark:text-white text-sm">{{ profile.name }}</div> |
| </td> |
| <td class="px-3 py-2"> |
| <div v-if="profile.description" class="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate"> |
| {{ profile.description }} |
| </div> |
| <div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div> |
| </td> |
| <td class="px-3 py-2"> |
| <Icon |
| :name="profile.enable_grease ? 'check' : 'lock'" |
| size="sm" |
| :class="profile.enable_grease ? 'text-green-500' : 'text-gray-400'" |
| /> |
| </td> |
| <td class="px-3 py-2"> |
| <div v-if="profile.alpn_protocols?.length" class="flex flex-wrap gap-1"> |
| <span |
| v-for="proto in profile.alpn_protocols.slice(0, 3)" |
| :key="proto" |
| class="badge badge-primary text-xs" |
| > |
| {{ proto }} |
| </span> |
| <span v-if="profile.alpn_protocols.length > 3" class="text-xs text-gray-500"> |
| +{{ profile.alpn_protocols.length - 3 }} |
| </span> |
| </div> |
| <div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div> |
| </td> |
| <td class="px-3 py-2"> |
| <div class="flex items-center gap-1"> |
| <button |
| @click="handleEdit(profile)" |
| class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400" |
| :title="t('common.edit')" |
| > |
| <Icon name="edit" size="sm" /> |
| </button> |
| <button |
| @click="handleDelete(profile)" |
| class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400" |
| :title="t('common.delete')" |
| > |
| <Icon name="trash" size="sm" /> |
| </button> |
| </div> |
| </td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| <template #footer> |
| <div class="flex justify-end"> |
| <button @click="$emit('close')" class="btn btn-secondary"> |
| {{ t('common.close') }} |
| </button> |
| </div> |
| </template> |
| |
| |
| <BaseDialog |
| :show="showCreateModal || showEditModal" |
| :title="showEditModal ? t('admin.tlsFingerprintProfiles.editProfile') : t('admin.tlsFingerprintProfiles.createProfile')" |
| width="wide" |
| :z-index="60" |
| @close="closeFormModal" |
| > |
| <form @submit.prevent="handleSubmit" class="space-y-4"> |
| |
| <div> |
| <label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.pasteYaml') }}</label> |
| <textarea |
| v-model="yamlInput" |
| rows="4" |
| class="input font-mono text-xs" |
| :placeholder="t('admin.tlsFingerprintProfiles.form.pasteYamlPlaceholder')" |
| @paste="handleYamlPaste" |
| /> |
| <div class="mt-1 flex items-center gap-2"> |
| <button type="button" @click="parseYamlInput" class="btn btn-secondary btn-sm"> |
| {{ t('admin.tlsFingerprintProfiles.form.parseYaml') }} |
| </button> |
| <p class="text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.form.pasteYamlHint') }} |
| <a href="https://tls.sub2api.org" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline">{{ t('admin.tlsFingerprintProfiles.form.openCollector') }}</a> |
| </p> |
| </div> |
| </div> |
| |
| <hr class="border-gray-200 dark:border-dark-600" /> |
| |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.name') }}</label> |
| <input |
| v-model="form.name" |
| type="text" |
| required |
| class="input" |
| :placeholder="t('admin.tlsFingerprintProfiles.form.namePlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.description') }}</label> |
| <input |
| v-model="form.description" |
| type="text" |
| class="input" |
| :placeholder="t('admin.tlsFingerprintProfiles.form.descriptionPlaceholder')" |
| /> |
| </div> |
| </div> |
| |
| |
| <div class="flex items-center gap-3"> |
| <button |
| type="button" |
| @click="form.enable_grease = !form.enable_grease" |
| :class="[ |
| 'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2', |
| form.enable_grease ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600' |
| ]" |
| > |
| <span |
| :class="[ |
| 'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', |
| form.enable_grease ? 'translate-x-4' : 'translate-x-0' |
| ]" |
| /> |
| </button> |
| <div> |
| <span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.tlsFingerprintProfiles.form.enableGrease') }} |
| </span> |
| <p class="text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.tlsFingerprintProfiles.form.enableGreaseHint') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuites') }}</label> |
| <textarea |
| v-model="fieldInputs.cipher_suites" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'0x1301, 0x1302, 0xc02c'" |
| /> |
| <p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuitesHint') }}</p> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.curves') }}</label> |
| <textarea |
| v-model="fieldInputs.curves" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'29, 23, 24'" |
| /> |
| <p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.curvesHint') }}</p> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.signatureAlgorithms') }}</label> |
| <textarea |
| v-model="fieldInputs.signature_algorithms" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'0x0403, 0x0804, 0x0401'" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.supportedVersions') }}</label> |
| <textarea |
| v-model="fieldInputs.supported_versions" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'0x0304, 0x0303'" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.keyShareGroups') }}</label> |
| <textarea |
| v-model="fieldInputs.key_share_groups" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'29, 23'" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.extensions') }}</label> |
| <textarea |
| v-model="fieldInputs.extensions" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'0x0000, 0x0005, 0x000a'" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pointFormats') }}</label> |
| <textarea |
| v-model="fieldInputs.point_formats" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'0'" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pskModes') }}</label> |
| <textarea |
| v-model="fieldInputs.psk_modes" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'1'" |
| /> |
| </div> |
| </div> |
| |
| |
| <div> |
| <label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.alpnProtocols') }}</label> |
| <textarea |
| v-model="fieldInputs.alpn_protocols" |
| rows="2" |
| class="input font-mono text-xs" |
| :placeholder="'h2, http/1.1'" |
| /> |
| </div> |
| </form> |
| |
| <template #footer> |
| <div class="flex justify-end gap-3"> |
| <button @click="closeFormModal" type="button" class="btn btn-secondary"> |
| {{ t('common.cancel') }} |
| </button> |
| <button @click="handleSubmit" :disabled="submitting" class="btn btn-primary"> |
| <Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" /> |
| {{ showEditModal ? t('common.update') : t('common.create') }} |
| </button> |
| </div> |
| </template> |
| </BaseDialog> |
| |
| |
| <ConfirmDialog |
| :show="showDeleteDialog" |
| :title="t('admin.tlsFingerprintProfiles.deleteProfile')" |
| :message="t('admin.tlsFingerprintProfiles.deleteConfirmMessage', { name: deletingProfile?.name })" |
| :confirm-text="t('common.delete')" |
| :cancel-text="t('common.cancel')" |
| :danger="true" |
| @confirm="confirmDelete" |
| @cancel="showDeleteDialog = false" |
| /> |
| </BaseDialog> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, reactive, watch } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAppStore } from '@/stores/app' |
| import { adminAPI } from '@/api/admin' |
| import type { TLSFingerprintProfile } from '@/api/admin/tlsFingerprintProfile' |
| import BaseDialog from '@/components/common/BaseDialog.vue' |
| import ConfirmDialog from '@/components/common/ConfirmDialog.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const props = defineProps<{ |
| show: boolean |
| }>() |
| |
| const emit = defineEmits<{ |
| close: [] |
| }>() |
| |
| |
| void emit |
| |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| |
| const profiles = ref<TLSFingerprintProfile[]>([]) |
| const loading = ref(false) |
| const submitting = ref(false) |
| const showCreateModal = ref(false) |
| const showEditModal = ref(false) |
| const showDeleteDialog = ref(false) |
| const editingProfile = ref<TLSFingerprintProfile | null>(null) |
| const deletingProfile = ref<TLSFingerprintProfile | null>(null) |
| const yamlInput = ref('') |
| |
| |
| const fieldInputs = reactive({ |
| cipher_suites: '', |
| curves: '', |
| point_formats: '', |
| signature_algorithms: '', |
| alpn_protocols: '', |
| supported_versions: '', |
| key_share_groups: '', |
| psk_modes: '', |
| extensions: '' |
| }) |
| |
| const form = reactive({ |
| name: '', |
| description: null as string | null, |
| enable_grease: false |
| }) |
| |
| |
| watch(() => props.show, (newVal) => { |
| if (newVal) { |
| loadProfiles() |
| } |
| }) |
| |
| const loadProfiles = async () => { |
| loading.value = true |
| try { |
| profiles.value = await adminAPI.tlsFingerprintProfiles.list() |
| } catch (error) { |
| appStore.showError(t('admin.tlsFingerprintProfiles.loadFailed')) |
| console.error('Error loading TLS fingerprint profiles:', error) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| const resetForm = () => { |
| form.name = '' |
| form.description = null |
| form.enable_grease = false |
| fieldInputs.cipher_suites = '' |
| fieldInputs.curves = '' |
| fieldInputs.point_formats = '' |
| fieldInputs.signature_algorithms = '' |
| fieldInputs.alpn_protocols = '' |
| fieldInputs.supported_versions = '' |
| fieldInputs.key_share_groups = '' |
| fieldInputs.psk_modes = '' |
| fieldInputs.extensions = '' |
| yamlInput.value = '' |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const parseYamlInput = () => { |
| const text = yamlInput.value.trim() |
| if (!text) return |
| |
| |
| |
| const lines = text.split('\n') |
| |
| let foundName = false |
| |
| for (const line of lines) { |
| const trimmed = line.trim() |
| |
| if (!trimmed || trimmed.startsWith('#')) continue |
| |
| |
| const match = trimmed.match(/^(\w+):\s*(.+)$/) |
| if (!match) continue |
| |
| const [, key, rawValue] = match |
| const value = rawValue.trim() |
| |
| switch (key) { |
| case 'name': { |
| |
| const unquoted = value.replace(/^["']|["']$/g, '') |
| if (unquoted) { |
| form.name = unquoted |
| foundName = true |
| } |
| break |
| } |
| case 'enable_grease': |
| form.enable_grease = value === 'true' |
| break |
| case 'cipher_suites': |
| case 'curves': |
| case 'point_formats': |
| case 'signature_algorithms': |
| case 'supported_versions': |
| case 'key_share_groups': |
| case 'psk_modes': |
| case 'extensions': { |
| |
| const arrMatch = value.match(/^\[(.*)?\]$/) |
| if (arrMatch) { |
| const inner = arrMatch[1] || '' |
| fieldInputs[key as keyof typeof fieldInputs] = inner |
| .split(',') |
| .map(s => s.trim()) |
| .filter(s => s.length > 0) |
| .join(', ') |
| } |
| break |
| } |
| case 'alpn_protocols': { |
| |
| const arrMatch = value.match(/^\[(.*)?\]$/) |
| if (arrMatch) { |
| const inner = arrMatch[1] || '' |
| fieldInputs.alpn_protocols = inner |
| .split(',') |
| .map(s => s.trim().replace(/^["']|["']$/g, '')) |
| .filter(s => s.length > 0) |
| .join(', ') |
| } |
| break |
| } |
| } |
| } |
| |
| if (foundName) { |
| appStore.showSuccess(t('admin.tlsFingerprintProfiles.form.yamlParsed')) |
| } else { |
| appStore.showError(t('admin.tlsFingerprintProfiles.form.yamlParseFailed')) |
| } |
| } |
| |
| |
| const handleYamlPaste = () => { |
| |
| setTimeout(() => parseYamlInput(), 50) |
| } |
| |
| const closeFormModal = () => { |
| showCreateModal.value = false |
| showEditModal.value = false |
| editingProfile.value = null |
| resetForm() |
| } |
| |
| |
| const parseNumericArray = (input: string): number[] => { |
| if (!input.trim()) return [] |
| return input |
| .split(',') |
| .map(s => s.trim()) |
| .filter(s => s.length > 0) |
| .map(s => s.startsWith('0x') || s.startsWith('0X') ? parseInt(s, 16) : parseInt(s, 10)) |
| .filter(n => !isNaN(n)) |
| } |
| |
| |
| const parseStringArray = (input: string): string[] => { |
| if (!input.trim()) return [] |
| return input |
| .split(',') |
| .map(s => s.trim()) |
| .filter(s => s.length > 0) |
| } |
| |
| |
| const formatHex = (n: number): string => '0x' + n.toString(16).padStart(4, '0') |
| |
| |
| const formatNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).map(formatHex).join(', ') |
| |
| |
| const formatPlainNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).join(', ') |
| |
| const handleEdit = (profile: TLSFingerprintProfile) => { |
| editingProfile.value = profile |
| form.name = profile.name |
| form.description = profile.description |
| form.enable_grease = profile.enable_grease |
| fieldInputs.cipher_suites = formatNumericArray(profile.cipher_suites) |
| fieldInputs.curves = formatPlainNumericArray(profile.curves) |
| fieldInputs.point_formats = formatPlainNumericArray(profile.point_formats) |
| fieldInputs.signature_algorithms = formatNumericArray(profile.signature_algorithms) |
| fieldInputs.alpn_protocols = (profile.alpn_protocols ?? []).join(', ') |
| fieldInputs.supported_versions = formatNumericArray(profile.supported_versions) |
| fieldInputs.key_share_groups = formatPlainNumericArray(profile.key_share_groups) |
| fieldInputs.psk_modes = formatPlainNumericArray(profile.psk_modes) |
| fieldInputs.extensions = formatNumericArray(profile.extensions) |
| showEditModal.value = true |
| } |
| |
| const handleDelete = (profile: TLSFingerprintProfile) => { |
| deletingProfile.value = profile |
| showDeleteDialog.value = true |
| } |
| |
| const handleSubmit = async () => { |
| if (!form.name.trim()) { |
| appStore.showError(t('admin.tlsFingerprintProfiles.form.name') + ' ' + t('common.required')) |
| return |
| } |
| |
| submitting.value = true |
| try { |
| const data = { |
| name: form.name.trim(), |
| description: form.description?.trim() || null, |
| enable_grease: form.enable_grease, |
| cipher_suites: parseNumericArray(fieldInputs.cipher_suites), |
| curves: parseNumericArray(fieldInputs.curves), |
| point_formats: parseNumericArray(fieldInputs.point_formats), |
| signature_algorithms: parseNumericArray(fieldInputs.signature_algorithms), |
| alpn_protocols: parseStringArray(fieldInputs.alpn_protocols), |
| supported_versions: parseNumericArray(fieldInputs.supported_versions), |
| key_share_groups: parseNumericArray(fieldInputs.key_share_groups), |
| psk_modes: parseNumericArray(fieldInputs.psk_modes), |
| extensions: parseNumericArray(fieldInputs.extensions) |
| } |
| |
| if (showEditModal.value && editingProfile.value) { |
| await adminAPI.tlsFingerprintProfiles.update(editingProfile.value.id, data) |
| appStore.showSuccess(t('admin.tlsFingerprintProfiles.updateSuccess')) |
| } else { |
| await adminAPI.tlsFingerprintProfiles.create(data) |
| appStore.showSuccess(t('admin.tlsFingerprintProfiles.createSuccess')) |
| } |
| |
| closeFormModal() |
| loadProfiles() |
| } catch (error: any) { |
| appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.saveFailed')) |
| console.error('Error saving TLS fingerprint profile:', error) |
| } finally { |
| submitting.value = false |
| } |
| } |
| |
| const confirmDelete = async () => { |
| if (!deletingProfile.value) return |
| |
| try { |
| await adminAPI.tlsFingerprintProfiles.delete(deletingProfile.value.id) |
| appStore.showSuccess(t('admin.tlsFingerprintProfiles.deleteSuccess')) |
| showDeleteDialog.value = false |
| deletingProfile.value = null |
| loadProfiles() |
| } catch (error: any) { |
| appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.deleteFailed')) |
| console.error('Error deleting TLS fingerprint profile:', error) |
| } |
| } |
| </script> |
| |