modify: 授权

This commit is contained in:
2026-10-13 14:58:32 +08:00
parent 35f71fc858
commit d11497c790
5 changed files with 601 additions and 1 deletions
+4
View File
@@ -18,6 +18,8 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider'] ElDivider: typeof import('element-plus/es')['ElDivider']
ElFooter: typeof import('element-plus/es')['ElFooter'] ElFooter: typeof import('element-plus/es')['ElFooter']
@@ -59,6 +61,8 @@ declare global {
const ElButton: typeof import('element-plus/es')['ElButton'] const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard'] const ElCard: typeof import('element-plus/es')['ElCard']
const ElContainer: typeof import('element-plus/es')['ElContainer'] const ElContainer: typeof import('element-plus/es')['ElContainer']
const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
const ElDialog: typeof import('element-plus/es')['ElDialog'] const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDivider: typeof import('element-plus/es')['ElDivider'] const ElDivider: typeof import('element-plus/es')['ElDivider']
const ElFooter: typeof import('element-plus/es')['ElFooter'] const ElFooter: typeof import('element-plus/es')['ElFooter']
+5 -1
View File
@@ -27,6 +27,10 @@
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>Worker 管理</span> <span>Worker 管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/license">
<el-icon><Key /></el-icon>
<span>授权</span>
</el-menu-item>
<el-menu-item index="/about"> <el-menu-item index="/about">
<el-icon><InfoFilled /></el-icon> <el-icon><InfoFilled /></el-icon>
<span>关于</span> <span>关于</span>
@@ -46,7 +50,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Cpu, HomeFilled, InfoFilled, Monitor, Setting } from '@element-plus/icons-vue' import { Cpu, HomeFilled, InfoFilled, Key, Monitor, Setting } from '@element-plus/icons-vue'
</script> </script>
<style scoped> <style scoped>
+159
View File
@@ -0,0 +1,159 @@
/**
* 机器码授权 API(管理端点 /license/*)
*
* 授权状态与机器码属设备标识信息,导入 license 又会改变整个网关的插件加载行为,
* 故与 /admin/workers 同级:均要求超级管理员的 Bearer token。
*/
// 去掉尾斜杠,避免与以 '/' 开头的 path 拼接成 '//host...'(协议相对 URL 导致 ERR_NAME_NOT_RESOLVED)
const API_BASE: string = ((import.meta.env.VITE_API_BASE_URL as string) || '').replace(/\/+$/, '')
/** 指纹比对结论 */
export interface LicenseMatch {
ok: boolean
matched: number
total: number
reason: string
matched_sources: string[]
mismatched_sources: string[]
/** 可信来源不足、判定纳入了 weak 来源(安全性下降) */
degraded: boolean
}
/** license 载荷 */
export interface LicensePayload {
v: number
lic_id: string
customer: string
machine_code: string
sources: Record<string, string>
plugins: string[]
not_before: string | null
expires_at: string | null
issued_at: string | null
note: string
}
/** 整份 license 的校验结论 */
export interface LicenseStatus {
ok: boolean
/** 原因码:ok / expired / machine_mismatch / signature_invalid / license_missing … */
code: string
message: string
license_path: string
machine_code: string
public_key_fingerprint: string
sources: Record<string, string>
warnings: string[]
match: LicenseMatch | null
payload: LicensePayload | null
licensed_plugins: string[]
}
/** 单个硬件来源的采集明细 */
export interface SourceDetail {
name: string
label: string
/** weak 来源不参与判定阈值(同镜像批量刷机时可能全机相同) */
weak: boolean
present: boolean
value: string
raw: string
detail: string
}
/** 插件视角的授权全景 */
export interface PluginOverview {
discovered: string[]
licensed: string[]
denied: Record<string, { code: string; message: string }>
loaded: string[]
denied_from_last_scan: Record<string, string>
}
export interface LicenseInfo {
enforce: boolean
enforce_forced_by_packaging: boolean
license_path: string
min_match: number
status: LicenseStatus
sources_detail: SourceDetail[]
warnings: string[]
/** 申请码:把本机指纹打包成一段可抄写文本,发给厂商签发 */
request_code: string
plugins: PluginOverview
}
export interface LicenseUploadResult {
success: boolean
license_path: string
status: LicenseStatus
licensed_plugins: string[]
restart_required: boolean
pending_plugins: string[]
restart_hint: string
}
async function request<T>(path: string, token: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers || {}),
},
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body?.detail) {
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
}
} catch {
/* ignore non-JSON error body */
}
throw new Error(detail)
}
return res.json() as Promise<T>
}
/** 读取授权状态、机器码、申请码与各来源明细 */
export function getLicenseInfo(token: string) {
return request<LicenseInfo>('/api/v1/license/info', token)
}
/** 重新读取磁盘上的授权文件(手工替换文件后无需重启即可刷新状态) */
export function reloadLicense(token: string) {
return request<{ success: boolean; status: LicenseStatus }>('/api/v1/license/reload', token, {
method: 'POST',
})
}
/**
* 导入 license 文件。
*
* 单独实现而不复用 request():multipart 必须让浏览器自行生成 boundary,
* 手写 Content-Type 会把 boundary 弄丢导致后端解析失败。
*/
export async function uploadLicense(token: string, file: File): Promise<LicenseUploadResult> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${API_BASE}/api/v1/license/file`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body?.detail) {
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
}
} catch {
/* ignore non-JSON error body */
}
throw new Error(detail)
}
return res.json() as Promise<LicenseUploadResult>
}
+6
View File
@@ -22,6 +22,12 @@ const router = createRouter({
// 懒加载 Worker 管理页面(实例盘点 + 单/批量重启) // 懒加载 Worker 管理页面(实例盘点 + 单/批量重启)
component: () => import('../views/WorkerView.vue'), component: () => import('../views/WorkerView.vue'),
}, },
{
path: '/license',
name: 'license',
// 懒加载机器码授权页(机器码/申请码展示 + 授权状态 + 导入授权文件)
component: () => import('../views/LicenseView.vue'),
},
{ {
path: '/', path: '/',
name: 'config', name: 'config',
+427
View File
@@ -0,0 +1,427 @@
<template>
<div class="license-container">
<el-card class="toolbar-card" shadow="hover">
<template v-if="!token">
<el-form inline>
<el-form-item label="用户名">
<el-input
v-model="username"
placeholder="用户名"
style="width: 200px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="password"
type="password"
show-password
placeholder="密码"
style="width: 220px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="doLogin">
登录
</el-button>
</el-form>
<el-alert
type="info"
:closable="false"
title="机器码与授权状态属设备标识信息,仅超级管理员可查看与导入授权。"
show-icon
style="margin-top: 8px"
/>
</template>
<template v-else>
<div class="login-bar">
<el-tag effect="plain" type="primary">{{ currentUser?.username || '已登录' }}</el-tag>
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">超级管理员</el-tag>
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
<el-button type="primary" :loading="loading" style="margin-left: 12px" @click="fetchInfo">
刷新
</el-button>
<el-button type="info" plain @click="logout">退出</el-button>
</div>
</template>
</el-card>
<el-alert
v-if="token && info"
:type="statusType"
:closable="false"
show-icon
:title="statusTitle"
:description="statusDescription"
style="margin-bottom: 16px"
/>
<template v-if="token && info">
<el-card class="machine-card" shadow="hover">
<template #header>
<div class="card-header">本机机器码</div>
</template>
<div class="machine-code">{{ info.status.machine_code || '(未采集到硬件指纹)' }}</div>
<div class="hint">
把机器码(或下方申请码)发给厂商即可申请授权。申请码包含各硬件来源指纹,
厂商据此签发的授权可容忍更换单块网卡或单颗 eMMC;只报机器码则只能签出精确匹配的授权。
</div>
<div class="copy-bar">
<el-button size="small" :disabled="!info.status.machine_code" @click="copy(info.status.machine_code)">
复制机器码
</el-button>
<el-button size="small" type="primary" :disabled="!info.request_code" @click="copy(info.request_code)">
复制申请码
</el-button>
<el-input
v-model="info.request_code"
readonly
size="small"
class="request-code"
placeholder="(申请码生成失败)"
/>
</div>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">授权信息</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="授权文件">
{{ info.license_path }}
</el-descriptions-item>
<el-descriptions-item label="校验方式">
{{ info.enforce ? '强制校验' : '未启用(开发/测试)' }}
<el-tag v-if="info.enforce_forced_by_packaging" type="warning" size="small" style="margin-left: 6px">
打包产物强制
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="授权编号">
{{ info.status.payload?.lic_id || '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户">
{{ info.status.payload?.customer || '—' }}
</el-descriptions-item>
<el-descriptions-item label="签发时间">
{{ info.status.payload?.issued_at || '—' }}
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ info.status.payload?.not_before || '立即生效' }} →
{{ info.status.payload?.expires_at || '永久' }}
</el-descriptions-item>
<el-descriptions-item label="指纹比对">
{{ info.status.match?.reason || '—' }}
<el-tag v-if="info.status.match?.degraded" type="warning" size="small" style="margin-left: 6px">
已降级
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="厂商公钥指纹">
<code>{{ info.status.public_key_fingerprint || '(未配置公钥)' }}</code>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ info.status.payload?.note || '—' }}
</el-descriptions-item>
</el-descriptions>
<el-alert
v-for="(w, i) in info.warnings"
:key="i"
type="warning"
:closable="false"
:title="w"
show-icon
style="margin-top: 8px"
/>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">
插件授权
<span class="count-tip">
盘扫 {{ info.plugins.discovered.length }} 个 / 已授权 {{ info.plugins.licensed.length }}
</span>
</div>
</template>
<el-table :data="pluginRows" size="small" stripe>
<el-table-column prop="type" label="插件类型" min-width="180" />
<el-table-column label="授权" width="100">
<template #default="{ row }">
<el-tag :type="row.authorized ? 'success' : 'danger'" size="small">
{{ row.authorized ? '已授权' : '未授权' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已加载" width="100">
<template #default="{ row }">
<el-tag :type="row.loaded ? 'primary' : 'info'" size="small">
{{ row.loaded ? '已加载' : '未加载' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="说明" min-width="280" show-overflow-tooltip />
</el-table>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">
硬件指纹来源
<span class="count-tip">带 * 的为 weak 来源,不参与判定阈值</span>
</div>
</template>
<el-table :data="info.sources_detail" size="small" stripe>
<el-table-column prop="name" label="来源" width="140" />
<el-table-column prop="label" label="说明" min-width="200" />
<el-table-column label="采集" width="90">
<template #default="{ row }">
<el-tag :type="row.present ? 'success' : 'info'" size="small">
{{ row.present ? '成功' : '缺失' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="值" min-width="260">
<template #default="{ row }">
<code>{{ row.value || '—' }}{{ row.weak && row.present ? ' *' : '' }}</code>
<span v-if="row.detail" class="source-detail">{{ row.detail }}</span>
</template>
</el-table-column>
</el-table>
<div class="hint">
可信来源越多,授权的容错越好。若这里只有 1 个来源或大量"缺失",
说明该硬件上的序列号不可读 —— 建议先核实再签发(换一块网卡就会失授权)。
</div>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">导入授权文件</div>
</template>
<div class="upload-bar">
<input ref="fileInput" type="file" accept=".lic,application/json" class="file-input" @change="onFilePicked" />
<el-button :loading="uploading" @click="fileInput?.click()">
{{ pickedFile ? `重新选择(当前:${pickedFile.name})` : '选择 license 文件' }}
</el-button>
<el-button type="primary" :loading="uploading" :disabled="!pickedFile" @click="doUpload">
导入并校验
</el-button>
<el-button :loading="loading" @click="doReload">从磁盘重读</el-button>
</div>
<div class="hint">
导入前会完整校验签名、机器码与有效期,**校验不通过不会覆盖**现有授权文件。
新授权的插件需重启网关后才会加载。
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminAuth } from '@/composables/useAdminAuth'
import {
getLicenseInfo,
reloadLicense,
uploadLicense,
type LicenseInfo,
} from '@/api/license'
const { token, currentUser, username, password, logging, doLogin, logout } = useAdminAuth()
const info = ref<LicenseInfo | null>(null)
const loading = ref(false)
const uploading = ref(false)
const pickedFile = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const statusType = computed(() => {
if (!info.value) return 'info'
if (!info.value.enforce) return 'info'
return info.value.status.ok ? 'success' : 'error'
})
const statusTitle = computed(() => {
const i = info.value
if (!i) return ''
if (!i.enforce) return '授权校验未启用(开发/测试模式)'
return i.status.ok ? '授权有效' : `授权无效:${i.status.message}`
})
const statusDescription = computed(() => {
const i = info.value
if (!i) return ''
if (!i.enforce) {
return `当前未执行拒绝;真实校验结果:${i.status.message}`
}
if (i.status.ok) {
const plugins = i.status.licensed_plugins
return `已授权插件:${plugins.length ? plugins.join('、') : '无'}`
}
const denied = Object.keys(i.plugins.denied)
return denied.length
? `以下插件不会被加载:${denied.join('、')}`
: '导入有效授权文件后重启网关即可加载插件。'
})
interface PluginRow {
type: string
authorized: boolean
loaded: boolean
reason: string
}
const pluginRows = computed<PluginRow[]>(() => {
const i = info.value
if (!i) return []
const loaded = new Set(i.plugins.loaded)
return i.plugins.discovered.map((type) => {
const denied = i.plugins.denied[type]
const authorized = i.plugins.licensed.includes(type)
return {
type,
authorized,
loaded: loaded.has(type),
reason: denied
? denied.message
: authorized
? '在白名单内'
: '未在授权白名单内',
}
})
})
async function fetchInfo() {
if (!token.value) return
loading.value = true
try {
info.value = await getLicenseInfo(token.value)
} catch (e: any) {
ElMessage.error(`读取授权状态失败:${e.message}`)
} finally {
loading.value = false
}
}
async function copy(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
// 非 HTTPS / 无剪贴板权限时回退到选中提示,避免静默失败
ElMessage.warning('复制失败,请手动选中复制')
}
}
function onFilePicked(ev: Event) {
const input = ev.target as HTMLInputElement
pickedFile.value = input.files?.[0] ?? null
}
async function doUpload() {
if (!pickedFile.value) return
uploading.value = true
try {
const res = await uploadLicense(token.value, pickedFile.value)
ElMessage.success(`导入成功:${res.status.message}`)
if (res.restart_required) {
ElMessage.warning(res.restart_hint)
}
pickedFile.value = null
if (fileInput.value) fileInput.value.value = ''
await fetchInfo()
} catch (e: any) {
ElMessage.error(`导入失败:${e.message}`)
} finally {
uploading.value = false
}
}
async function doReload() {
loading.value = true
try {
const res = await reloadLicense(token.value)
ElMessage.success(`已重读授权:${res.status.message}`)
await fetchInfo()
} catch (e: any) {
ElMessage.error(`重读失败:${e.message}`)
} finally {
loading.value = false
}
}
onMounted(() => {
if (token.value) fetchInfo()
})
</script>
<style scoped>
.license-container {
max-width: 1100px;
margin: 0 auto;
}
.toolbar-card,
.machine-card,
.detail-card {
border-radius: 12px;
margin-bottom: 16px;
border: 1px solid var(--cm-border);
background: var(--cm-bg-soft);
}
.card-header {
display: flex;
align-items: center;
color: var(--cm-text);
}
.login-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.count-tip {
margin-left: 12px;
color: var(--cm-text-muted);
font-size: 12px;
}
.machine-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 26px;
letter-spacing: 2px;
color: var(--cm-text);
padding: 6px 0 10px;
user-select: all;
}
.hint {
color: var(--cm-text-muted);
font-size: 12px;
line-height: 1.7;
margin-top: 8px;
}
.copy-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.request-code {
max-width: 460px;
}
.source-detail {
margin-left: 8px;
color: var(--cm-text-muted);
font-size: 12px;
}
.upload-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.file-input {
display: none;
}
</style>