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:
@@ -24,6 +24,7 @@ import dataManagementAPI from './dataManagement'
|
||||
import apiKeysAPI from './apiKeys'
|
||||
import scheduledTestsAPI from './scheduledTests'
|
||||
import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -49,7 +50,8 @@ export const adminAPI = {
|
||||
dataManagement: dataManagementAPI,
|
||||
apiKeys: apiKeysAPI,
|
||||
scheduledTests: scheduledTestsAPI,
|
||||
backup: backupAPI
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -73,7 +75,8 @@ export {
|
||||
dataManagementAPI,
|
||||
apiKeysAPI,
|
||||
scheduledTestsAPI,
|
||||
backupAPI
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
@@ -82,3 +85,4 @@ export default adminAPI
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
||||
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
||||
|
||||
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin TLS Fingerprint Profile API endpoints
|
||||
* Handles TLS fingerprint profile CRUD for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* TLS fingerprint profile interface
|
||||
*/
|
||||
export interface TLSFingerprintProfile {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
enable_grease: boolean
|
||||
cipher_suites: number[]
|
||||
curves: number[]
|
||||
point_formats: number[]
|
||||
signature_algorithms: number[]
|
||||
alpn_protocols: string[]
|
||||
supported_versions: number[]
|
||||
key_share_groups: number[]
|
||||
psk_modes: number[]
|
||||
extensions: number[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profile request
|
||||
*/
|
||||
export interface CreateProfileRequest {
|
||||
name: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile request
|
||||
*/
|
||||
export interface UpdateProfileRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
export async function list(): Promise<TLSFingerprintProfile[]> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile[]>('/admin/tls-fingerprint-profiles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(profileData: CreateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.post<TLSFingerprintProfile>('/admin/tls-fingerprint-profiles', profileData)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, updates: UpdateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.put<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProfile(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const tlsFingerprintProfileAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProfile
|
||||
}
|
||||
|
||||
export default tlsFingerprintProfileAPI
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal file
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal 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>
|
||||
@@ -2304,7 +2304,9 @@ export default {
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS Fingerprint Simulation',
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint',
|
||||
defaultProfile: 'Built-in Default',
|
||||
randomProfile: 'Random'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: 'Session ID Masking',
|
||||
@@ -4588,6 +4590,62 @@ export default {
|
||||
failedToSave: 'Failed to save rule',
|
||||
failedToDelete: 'Failed to delete rule',
|
||||
failedToToggle: 'Failed to toggle status'
|
||||
},
|
||||
|
||||
// TLS Fingerprint Profiles
|
||||
tlsFingerprintProfiles: {
|
||||
title: 'TLS Fingerprint Profiles',
|
||||
description: 'Manage TLS fingerprint profiles for simulating specific client TLS handshake characteristics',
|
||||
createProfile: 'Create Profile',
|
||||
editProfile: 'Edit Profile',
|
||||
deleteProfile: 'Delete Profile',
|
||||
noProfiles: 'No profiles configured',
|
||||
createFirstProfile: 'Create your first TLS fingerprint profile',
|
||||
|
||||
columns: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
grease: 'GREASE',
|
||||
alpn: 'ALPN',
|
||||
actions: 'Actions'
|
||||
},
|
||||
|
||||
form: {
|
||||
pasteYaml: 'Paste YAML Configuration',
|
||||
pasteYamlPlaceholder: 'Paste YAML output from TLS Fingerprint Collector here...',
|
||||
pasteYamlHint: 'Paste the YAML copied from TLS Fingerprint Collector to auto-fill all fields.',
|
||||
openCollector: 'Open Collector',
|
||||
parseYaml: 'Parse YAML',
|
||||
yamlParsed: 'YAML parsed successfully, fields auto-filled',
|
||||
yamlParseFailed: 'Failed to parse YAML: name field not found',
|
||||
name: 'Profile Name',
|
||||
namePlaceholder: 'e.g. macOS Node.js v24',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Optional description for this profile',
|
||||
enableGrease: 'Enable GREASE',
|
||||
enableGreaseHint: 'Insert GREASE values in TLS ClientHello extensions',
|
||||
cipherSuites: 'Cipher Suites',
|
||||
cipherSuitesHint: 'Comma-separated hex values, e.g. 0x1301, 0x1302, 0xc02c',
|
||||
curves: 'Elliptic Curves',
|
||||
curvesHint: 'Comma-separated curve IDs',
|
||||
pointFormats: 'Point Formats',
|
||||
signatureAlgorithms: 'Signature Algorithms',
|
||||
alpnProtocols: 'ALPN Protocols',
|
||||
alpnProtocolsHint: 'Comma-separated, e.g. h2, http/1.1',
|
||||
supportedVersions: 'Supported TLS Versions',
|
||||
keyShareGroups: 'Key Share Groups',
|
||||
pskModes: 'PSK Modes',
|
||||
extensions: 'Extensions'
|
||||
},
|
||||
|
||||
deleteConfirm: 'Delete Profile',
|
||||
deleteConfirmMessage: 'Are you sure you want to delete profile "{name}"? Accounts using this profile will fall back to the built-in default.',
|
||||
createSuccess: 'Profile created successfully',
|
||||
updateSuccess: 'Profile updated successfully',
|
||||
deleteSuccess: 'Profile deleted successfully',
|
||||
loadFailed: 'Failed to load profiles',
|
||||
saveFailed: 'Failed to save profile',
|
||||
deleteFailed: 'Failed to delete profile'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2448,7 +2448,9 @@ export default {
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹',
|
||||
defaultProfile: '内置默认',
|
||||
randomProfile: '随机'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: '会话 ID 伪装',
|
||||
@@ -4752,6 +4754,62 @@ export default {
|
||||
failedToSave: '保存规则失败',
|
||||
failedToDelete: '删除规则失败',
|
||||
failedToToggle: '切换状态失败'
|
||||
},
|
||||
|
||||
// TLS 指纹模板
|
||||
tlsFingerprintProfiles: {
|
||||
title: 'TLS 指纹模板',
|
||||
description: '管理 TLS 指纹模板,用于模拟特定客户端的 TLS 握手特征',
|
||||
createProfile: '创建模板',
|
||||
editProfile: '编辑模板',
|
||||
deleteProfile: '删除模板',
|
||||
noProfiles: '暂无模板',
|
||||
createFirstProfile: '创建你的第一个 TLS 指纹模板',
|
||||
|
||||
columns: {
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
grease: 'GREASE',
|
||||
alpn: 'ALPN',
|
||||
actions: '操作'
|
||||
},
|
||||
|
||||
form: {
|
||||
pasteYaml: '粘贴 YAML 配置',
|
||||
pasteYamlPlaceholder: '将 TLS 指纹采集器复制的 YAML 粘贴到这里...',
|
||||
pasteYamlHint: '粘贴从 TLS 指纹采集器复制的 YAML 配置,自动填充所有字段。',
|
||||
openCollector: '打开采集器',
|
||||
parseYaml: '解析 YAML',
|
||||
yamlParsed: 'YAML 解析成功,字段已自动填充',
|
||||
yamlParseFailed: 'YAML 解析失败:未找到 name 字段',
|
||||
name: '模板名称',
|
||||
namePlaceholder: '例如 macOS Node.js v24',
|
||||
description: '描述',
|
||||
descriptionPlaceholder: '可选的模板描述',
|
||||
enableGrease: '启用 GREASE',
|
||||
enableGreaseHint: '在 TLS ClientHello 扩展中插入 GREASE 值',
|
||||
cipherSuites: '密码套件',
|
||||
cipherSuitesHint: '逗号分隔的十六进制值,例如 0x1301, 0x1302, 0xc02c',
|
||||
curves: '椭圆曲线',
|
||||
curvesHint: '逗号分隔的曲线 ID',
|
||||
pointFormats: '点格式',
|
||||
signatureAlgorithms: '签名算法',
|
||||
alpnProtocols: 'ALPN 协议',
|
||||
alpnProtocolsHint: '逗号分隔,例如 h2, http/1.1',
|
||||
supportedVersions: '支持的 TLS 版本',
|
||||
keyShareGroups: '密钥共享组',
|
||||
pskModes: 'PSK 模式',
|
||||
extensions: '扩展'
|
||||
},
|
||||
|
||||
deleteConfirm: '删除模板',
|
||||
deleteConfirmMessage: '确定要删除模板 "{name}" 吗?使用此模板的账号将回退到内置默认值。',
|
||||
createSuccess: '模板创建成功',
|
||||
updateSuccess: '模板更新成功',
|
||||
deleteSuccess: '模板删除成功',
|
||||
loadFailed: '加载模板失败',
|
||||
saveFailed: '保存模板失败',
|
||||
deleteFailed: '删除模板失败'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -724,6 +724,7 @@ export interface Account {
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
enable_tls_fingerprint?: boolean | null
|
||||
tls_fingerprint_profile_id?: number | null
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
|
||||
@@ -73,6 +73,16 @@
|
||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- TLS Fingerprint Profiles -->
|
||||
<button
|
||||
@click="showTLSFingerprintProfiles = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.tlsFingerprintProfiles.title')"
|
||||
>
|
||||
<Icon name="lock" size="md" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -289,6 +299,7 @@
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
<TLSFingerprintProfilesModal :show="showTLSFingerprintProfiles" @close="showTLSFingerprintProfiles = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -326,6 +337,7 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
||||
@@ -366,6 +378,7 @@ const showReAuth = ref(false)
|
||||
const showTest = ref(false)
|
||||
const showStats = ref(false)
|
||||
const showErrorPassthrough = ref(false)
|
||||
const showTLSFingerprintProfiles = ref(false)
|
||||
const edAcc = ref<Account | null>(null)
|
||||
const tempUnschedAcc = ref<Account | null>(null)
|
||||
const deletingAcc = ref<Account | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user