feat(tls-fingerprint): 新增 TLS 指纹 Profile 数据库管理及代码质量优化

新增功能:
- 新增 TLS 指纹 Profile CRUD 管理(Ent schema + 迁移 + Admin API + 前端管理界面)
- 支持账号绑定数据库中的自定义 TLS Profile,或随机选择(profile_id=-1)
- HTTPUpstream.DoWithTLS 接口从 bool 改为 *tlsfingerprint.Profile,支持按账号指定 Profile
- AccountUsageService 注入 TLSFingerprintProfileService,统一 usage 场景与网关的 Profile 解析逻辑

代码优化:
- 删除已被 TLSFingerprintProfileService 完全取代的 registry.go 死代码(418 行)
- 提取 3 个 dialer 的重复 TLS 握手逻辑为 performTLSHandshake() 共用函数
- 修复 GetTLSFingerprintProfileID 缺少 json.Number 处理的 bug
- gateway_service.Forward 中 ResolveTLSProfile 从重试循环内重复调用改为预解析局部变量
- 删除冗余的 buildClientHelloSpec() 单行 wrapper 和 int64(e.ID) 无效转换
- tls_fingerprint_profile_cache.go 日志从 log.Printf 改为 slog 结构化日志
- dialer_capture_test.go 添加 //go:build integration 标签,防止 CI 失败
- 去重 TestProfileExpectation 类型至共享 test_types_test.go
- 修复 9 个测试文件缺少 tlsfingerprint import 的编译错误
- 修复 error_policy_integration_test.go 中 handleError 回调签名被错误替换的问题
This commit is contained in:
shaw
2026-03-27 14:23:28 +08:00
parent ef5c8e6839
commit 1854050df3
70 changed files with 8095 additions and 1037 deletions

View File

@@ -2169,6 +2169,14 @@
/>
</button>
</div>
<!-- Profile selector -->
<div v-if="tlsFingerprintEnabled" class="mt-3">
<select v-model="tlsFingerprintProfileId" class="input">
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</div>
<!-- Session ID Masking -->
@@ -3082,6 +3090,8 @@ const umqModeOptions = computed(() => [
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
const tlsFingerprintEnabled = ref(false)
const tlsFingerprintProfileId = ref<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
@@ -3247,6 +3257,10 @@ watch(
() => props.show,
(newVal) => {
if (newVal) {
// Load TLS fingerprint profiles
adminAPI.tlsFingerprintProfiles.list()
.then(profiles => { tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) })
.catch(() => { tlsFingerprintProfiles.value = [] })
// Modal opened - fill related models
allowedModels.value = [...getModelsByPlatform(form.platform)]
// Antigravity: 默认使用映射模式并填充默认映射
@@ -3747,6 +3761,7 @@ const resetForm = () => {
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
tlsFingerprintProfileId.value = null
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
@@ -4825,6 +4840,9 @@ const handleAnthropicExchange = async (authCode: string) => {
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
if (tlsFingerprintProfileId.value) {
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
}
}
// Add session ID masking settings
@@ -4940,6 +4958,9 @@ const handleCookieAuth = async (sessionKey: string) => {
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
if (tlsFingerprintProfileId.value) {
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
}
}
// Add session ID masking settings

View File

@@ -1504,6 +1504,14 @@
/>
</button>
</div>
<!-- Profile selector -->
<div v-if="tlsFingerprintEnabled" class="mt-3">
<select v-model="tlsFingerprintProfileId" class="input">
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</div>
<!-- Session ID Masking -->
@@ -1841,6 +1849,8 @@ const umqModeOptions = computed(() => [
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
const tlsFingerprintEnabled = ref(false)
const tlsFingerprintProfileId = ref<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
@@ -2255,11 +2265,21 @@ watch(
}
if (!wasShow || newAccount !== previousAccount) {
syncFormFromAccount(newAccount)
loadTLSProfiles()
}
},
{ immediate: true }
)
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
@@ -2458,6 +2478,7 @@ function loadQuotaControlSettings(account: Account) {
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
tlsFingerprintProfileId.value = null
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
@@ -2495,6 +2516,7 @@ function loadQuotaControlSettings(account: Account) {
if (account.enable_tls_fingerprint === true) {
tlsFingerprintEnabled.value = true
}
tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
// Load session ID masking setting
if (account.session_id_masking_enabled === true) {
@@ -2932,8 +2954,14 @@ const handleSubmit = async () => {
// TLS fingerprint setting
if (tlsFingerprintEnabled.value) {
newExtra.enable_tls_fingerprint = true
if (tlsFingerprintProfileId.value) {
newExtra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
} else {
delete newExtra.tls_fingerprint_profile_id
}
} else {
delete newExtra.enable_tls_fingerprint
delete newExtra.tls_fingerprint_profile_id
}
// Session ID masking setting

View File

@@ -0,0 +1,625 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.tlsFingerprintProfiles.title')"
width="wide"
@close="$emit('close')"
>
<div class="space-y-4">
<!-- Header -->
<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>
<!-- Profiles Table -->
<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>
<!-- Create/Edit Modal -->
<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">
<!-- Paste YAML -->
<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" />
<!-- Basic Info -->
<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>
<!-- GREASE Toggle -->
<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>
<!-- TLS Array Fields - 2 column grid -->
<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>
<!-- ALPN Protocols - full width -->
<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>
<!-- Delete Confirmation -->
<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: []
}>()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void emit // suppress unused warning - emit is used via $emit in template
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('')
// Raw string inputs for array fields
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
})
// Load profiles when dialog opens
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 = ''
}
/**
* Parse YAML output from tls-fingerprint-web and fill form fields.
* Expected format:
* # comment lines
* profile_key:
* name: "Profile Name"
* enable_grease: false
* cipher_suites: [4866, 4867, ...]
* alpn_protocols: ["h2", "http/1.1"]
* ...
*/
const parseYamlInput = () => {
const text = yamlInput.value.trim()
if (!text) return
// Simple YAML parser for flat key-value structure
// Extracts "key: value" lines, handling arrays like [1, 2, 3] and ["h2", "http/1.1"]
const lines = text.split('\n')
let foundName = false
for (const line of lines) {
const trimmed = line.trim()
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) continue
// Match "key: value" pattern (must have at least 2 leading spaces to be a property)
const match = trimmed.match(/^(\w+):\s*(.+)$/)
if (!match) continue
const [, key, rawValue] = match
const value = rawValue.trim()
switch (key) {
case 'name': {
// Remove surrounding quotes
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': {
// Parse YAML array: [1, 2, 3] — values are decimal integers from tls-fingerprint-web
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': {
// Parse string array: ["h2", "http/1.1"]
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'))
}
}
// Auto-parse on paste event
const handleYamlPaste = () => {
// Use nextTick to ensure v-model has updated
setTimeout(() => parseYamlInput(), 50)
}
const closeFormModal = () => {
showCreateModal.value = false
showEditModal.value = false
editingProfile.value = null
resetForm()
}
// Parse a comma-separated string of numbers supporting both hex (0x...) and decimal
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))
}
// Parse a comma-separated string of string values
const parseStringArray = (input: string): string[] => {
if (!input.trim()) return []
return input
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0)
}
// Format a number as hex with 0x prefix and 4-digit padding
const formatHex = (n: number): string => '0x' + n.toString(16).padStart(4, '0')
// Format numeric arrays for display in textarea (null-safe)
const formatNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).map(formatHex).join(', ')
// For point_formats and psk_modes (uint8), show as plain numbers (null-safe)
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>