454 lines
16 KiB
Vue
454 lines
16 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* known_hosts 管理。
|
||
*
|
||
* # 设计立场
|
||
*
|
||
* 这个面板的存在理由只有一个:**让「忘记某台主机」这件事变得容易**。
|
||
*
|
||
* 主机密钥校验是 SSH 抵御中间人的核心机制,但它的最大敌人不是攻击者,
|
||
* 而是用户自己被卡住时的挫败感 ——「指纹变了,连不上,怎么办?」。
|
||
* 如果这时唯一的出路是去翻文档、找 known_hosts 文件、手工删行,
|
||
* 用户就会转向「关闭校验」这种彻底破坏安全性的做法。
|
||
*
|
||
* 所以这里提供两条路:
|
||
* - 单条遗忘(服务器重装后重新握手)——正常流程。
|
||
* - 全部清空(最后手段,带明确警告)。
|
||
*
|
||
* # 指纹变更历史为什么单独展示
|
||
*
|
||
* 一条**已接受**的变更(服务器重装、管理员换密钥)和一条**刚发生**的变更,
|
||
* 对用户的意义完全不同。只显示「最新指纹」会丢掉这个上下文,
|
||
* 让用户误以为「这台机器一直没问题」——而实际上它已经换过密钥。
|
||
*/
|
||
import { computed, ref } from 'vue'
|
||
import {
|
||
AlertTriangle,
|
||
Check,
|
||
ClipboardPaste,
|
||
Download,
|
||
History,
|
||
Loader2,
|
||
Server,
|
||
ShieldCheck,
|
||
Trash2
|
||
} from '@lucide/vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { useTerminalStore } from '@/stores/terminalStore'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle
|
||
} from '@/components/ui/dialog'
|
||
import { createLogger } from '@/lib/logger'
|
||
import type { KnownHost } from '@/types/terminal'
|
||
|
||
const logger = createLogger('terminal')
|
||
const store = useTerminalStore()
|
||
|
||
const search = ref('')
|
||
|
||
const filtered = computed(() => {
|
||
const q = search.value.trim().toLowerCase()
|
||
if (!q) return store.knownHosts
|
||
return store.knownHosts.filter(
|
||
h =>
|
||
h.host.toLowerCase().includes(q) ||
|
||
h.fingerprint.toLowerCase().includes(q) ||
|
||
h.keyType.toLowerCase().includes(q)
|
||
)
|
||
})
|
||
|
||
/** 曾发生过指纹变更的主机数量(用于顶部的风险提示) */
|
||
const changedCount = computed(() => store.knownHosts.filter(h => h.history.length > 0).length)
|
||
|
||
/** 展开查看变更历史的主机 key */
|
||
const expanded = ref<string | null>(null)
|
||
|
||
function hostKey(h: KnownHost): string {
|
||
return `${h.host}:${h.port}/${h.keyType}`
|
||
}
|
||
|
||
function toggleExpand(h: KnownHost) {
|
||
const k = hostKey(h)
|
||
expanded.value = expanded.value === k ? null : k
|
||
}
|
||
|
||
// ===== 单条遗忘 =====
|
||
|
||
const pendingForget = ref<KnownHost | null>(null)
|
||
const forgetBusy = ref(false)
|
||
|
||
async function submitForget() {
|
||
const h = pendingForget.value
|
||
if (!h) return
|
||
forgetBusy.value = true
|
||
try {
|
||
await store.forgetHost(h.host, h.port, h.keyType)
|
||
toast.success(`已忘记 ${h.host}:${h.port}`)
|
||
pendingForget.value = null
|
||
} catch (e) {
|
||
toast.error(`操作失败:${String(e)}`)
|
||
} finally {
|
||
forgetBusy.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 全部清空 =====
|
||
|
||
const clearOpen = ref(false)
|
||
const clearBusy = ref(false)
|
||
/** 清空是破坏性操作:要求用户输入确认词,避免误点 */
|
||
const clearConfirmText = ref('')
|
||
const CLEAR_KEYWORD = '清空'
|
||
|
||
async function submitClear() {
|
||
if (clearConfirmText.value.trim() !== CLEAR_KEYWORD) return
|
||
clearBusy.value = true
|
||
try {
|
||
await store.clearKnownHosts()
|
||
toast.success('已清空全部已知主机')
|
||
clearOpen.value = false
|
||
clearConfirmText.value = ''
|
||
} catch (e) {
|
||
toast.error(`清空失败:${String(e)}`)
|
||
} finally {
|
||
clearBusy.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 导入 / 导出 =====
|
||
|
||
const copied = ref(false)
|
||
let copiedTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
async function copyAll() {
|
||
try {
|
||
const text = await store.exportKnownHosts()
|
||
if (!text.trim()) {
|
||
toast.info('没有可导出的记录')
|
||
return
|
||
}
|
||
await navigator.clipboard.writeText(text)
|
||
copied.value = true
|
||
if (copiedTimer) clearTimeout(copiedTimer)
|
||
copiedTimer = setTimeout(() => (copied.value = false), 1800)
|
||
toast.success('已复制到剪贴板')
|
||
} catch (e) {
|
||
toast.error(`导出失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
const importOpen = ref(false)
|
||
const importText = ref('')
|
||
const importBusy = ref(false)
|
||
|
||
function openImport() {
|
||
importText.value = ''
|
||
importOpen.value = true
|
||
}
|
||
|
||
async function submitImport() {
|
||
const text = importText.value.trim()
|
||
if (!text) {
|
||
toast.error('请粘贴 known_hosts 内容')
|
||
return
|
||
}
|
||
importBusy.value = true
|
||
try {
|
||
const r = await store.importKnownHosts(text)
|
||
if (r.ok) {
|
||
toast.success(r.message)
|
||
importOpen.value = false
|
||
} else {
|
||
toast.error(r.message)
|
||
}
|
||
} catch (e) {
|
||
logger.error(`导入 known_hosts 失败:${String(e)}`)
|
||
toast.error(`导入失败:${String(e)}`)
|
||
} finally {
|
||
importBusy.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 展示辅助 =====
|
||
|
||
function formatDate(rfc3339: string): string {
|
||
if (!rfc3339) return '—'
|
||
const d = new Date(rfc3339)
|
||
if (Number.isNaN(d.getTime())) return rfc3339
|
||
return d.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
|
||
function keyTypeLabel(t: string): string {
|
||
const map: Record<string, string> = {
|
||
'ssh-ed25519': 'Ed25519',
|
||
'ssh-rsa': 'RSA',
|
||
'ecdsa-sha2-nistp256': 'ECDSA 256',
|
||
'ecdsa-sha2-nistp384': 'ECDSA 384',
|
||
'ecdsa-sha2-nistp521': 'ECDSA 521',
|
||
'ssh-dss': 'DSA'
|
||
}
|
||
return map[t] ?? t
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col h-full">
|
||
<!-- 头部 -->
|
||
<div class="shrink-0 flex items-center gap-2 px-4 h-12 border-b border-border">
|
||
<ShieldCheck class="size-4 text-muted-foreground" />
|
||
<h3 class="text-sm font-medium">已知主机</h3>
|
||
<span class="text-xs text-muted-foreground">({{ store.knownHosts.length }})</span>
|
||
<div class="flex-1" />
|
||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="openImport">
|
||
<ClipboardPaste class="size-3.5" />导入
|
||
</Button>
|
||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="copyAll">
|
||
<Check v-if="copied" class="size-3.5 text-emerald-600" />
|
||
<Download v-else class="size-3.5" />
|
||
导出
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-7 gap-1.5 text-xs text-destructive hover:text-destructive"
|
||
:disabled="store.knownHosts.length === 0"
|
||
@click="clearOpen = true"
|
||
>
|
||
<Trash2 class="size-3.5" />清空
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 风险提示:有变更历史时始终可见 -->
|
||
<div
|
||
v-if="changedCount > 0"
|
||
class="shrink-0 mx-3 mt-3 flex items-start gap-2 p-2.5 rounded-md
|
||
border border-amber-500/40 bg-amber-500/10"
|
||
>
|
||
<AlertTriangle class="size-3.5 shrink-0 mt-0.5 text-amber-600 dark:text-amber-400" />
|
||
<div class="text-[11px] text-amber-700 dark:text-amber-400">
|
||
<p class="font-medium">{{ changedCount }} 台主机曾发生指纹变更</p>
|
||
<p class="mt-0.5 opacity-90">
|
||
若不是您本人重装或更换了服务器密钥,请联系管理员核实。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索 -->
|
||
<div v-if="store.knownHosts.length > 0" class="shrink-0 px-3 pt-3">
|
||
<input
|
||
v-model="search"
|
||
placeholder="搜索主机、指纹或类型…"
|
||
class="w-full h-7 px-2.5 rounded-md border border-border bg-transparent
|
||
text-xs outline-none focus:border-primary/50 placeholder:text-muted-foreground"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 列表 -->
|
||
<div class="flex-1 min-h-0 overflow-y-auto p-3">
|
||
<div v-if="store.knownHosts.length === 0" class="py-16 text-center">
|
||
<ShieldCheck class="size-8 mx-auto text-muted-foreground/30 mb-3" />
|
||
<p class="text-sm text-muted-foreground mb-1">还没有已知主机记录</p>
|
||
<p class="text-xs text-muted-foreground/70">
|
||
首次连接某台 SSH 主机时,会请您核对并保存其密钥指纹
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else-if="filtered.length === 0" class="py-12 text-center">
|
||
<p class="text-sm text-muted-foreground">没有匹配「{{ search }}」的记录</p>
|
||
</div>
|
||
|
||
<div v-else class="space-y-1.5">
|
||
<div
|
||
v-for="h in filtered"
|
||
:key="hostKey(h)"
|
||
class="group rounded-md border border-border overflow-hidden
|
||
hover:border-primary/40 transition-colors"
|
||
:class="h.history.length > 0 ? 'border-amber-500/40' : ''"
|
||
>
|
||
<div class="flex items-start gap-3 px-3 py-2.5">
|
||
<Server class="size-4 shrink-0 mt-0.5 text-muted-foreground" />
|
||
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<span class="text-sm font-medium font-mono truncate">
|
||
{{ h.host }}<span v-if="h.port !== 22" class="text-muted-foreground">:{{ h.port }}</span>
|
||
</span>
|
||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted shrink-0">
|
||
{{ keyTypeLabel(h.keyType) }}
|
||
</span>
|
||
<button
|
||
v-if="h.history.length > 0"
|
||
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-700
|
||
dark:text-amber-400 shrink-0 inline-flex items-center gap-0.5
|
||
hover:bg-amber-500/25 transition-colors"
|
||
@click="toggleExpand(h)"
|
||
>
|
||
<History class="size-2.5" />
|
||
变更过 {{ h.history.length }} 次
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
class="mt-1 text-xs font-mono text-muted-foreground truncate"
|
||
:title="h.fingerprint"
|
||
>
|
||
{{ h.fingerprint }}
|
||
</div>
|
||
|
||
<div class="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground/80">
|
||
<span>首次 {{ formatDate(h.firstSeen) }}</span>
|
||
<span>最近确认 {{ formatDate(h.lastConfirmed) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="size-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity
|
||
text-destructive hover:text-destructive"
|
||
title="忘记该主机"
|
||
@click="pendingForget = h"
|
||
>
|
||
<Trash2 class="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 变更历史 -->
|
||
<div
|
||
v-if="expanded === hostKey(h)"
|
||
class="border-t border-border bg-muted/30 px-3 py-2 space-y-1.5"
|
||
>
|
||
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">指纹变更历史</p>
|
||
<div
|
||
v-for="(c, i) in h.history"
|
||
:key="i"
|
||
class="flex items-start gap-2 text-[11px]"
|
||
>
|
||
<span
|
||
class="shrink-0 mt-0.5 size-1.5 rounded-full"
|
||
:class="c.accepted ? 'bg-amber-500' : 'bg-muted-foreground/40'"
|
||
/>
|
||
<div class="min-w-0">
|
||
<span class="text-muted-foreground">{{ formatDate(c.changedAt) }}</span>
|
||
<span class="mx-1.5">·</span>
|
||
<span :class="c.accepted ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground'">
|
||
{{ c.accepted ? '已接受变更' : '已拒绝变更' }}
|
||
</span>
|
||
<div class="font-mono text-muted-foreground/70 truncate" :title="c.oldFingerprint">
|
||
原指纹 {{ c.oldFingerprint }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 忘记单条 ===== -->
|
||
<Dialog :open="pendingForget !== null" @update:open="v => !v && (pendingForget = null)">
|
||
<DialogContent class="max-w-sm" :show-close-button="false">
|
||
<DialogHeader>
|
||
<DialogTitle class="text-base">忘记该主机</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
将删除 <span class="font-mono">{{ pendingForget?.host }}:{{ pendingForget?.port }}</span>
|
||
的密钥指纹记录。下次连接时将重新提示核对。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="flex items-start gap-2 p-2 rounded bg-muted text-[11px] text-muted-foreground">
|
||
<AlertTriangle class="size-3.5 shrink-0 mt-0.5" />
|
||
<span>仅在确认该主机密钥确实已更换时执行。若出于「连不上就删掉」的动机,请先核实原因。</span>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" @click="pendingForget = null">取消</Button>
|
||
<Button variant="destructive" size="sm" :disabled="forgetBusy" @click="submitForget">
|
||
忘记
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- ===== 清空全部 ===== -->
|
||
<Dialog v-model:open="clearOpen">
|
||
<DialogContent class="max-w-sm" :show-close-button="false">
|
||
<DialogHeader>
|
||
<DialogTitle class="text-base text-destructive">清空全部已知主机</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
将删除全部 {{ store.knownHosts.length }} 条记录。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div class="rounded-md border border-destructive/40 bg-destructive/5 p-2.5">
|
||
<p class="text-[11px] text-destructive font-medium flex items-center gap-1 mb-1">
|
||
<AlertTriangle class="size-3" />这会降低连接安全性
|
||
</p>
|
||
<p class="text-[11px] text-muted-foreground">
|
||
清空后,所有主机的密钥校验都需要重新人工核对。在核对前,
|
||
连接可能被中间人攻击劫持而您不会收到提示。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="space-y-1.5">
|
||
<p class="text-xs text-muted-foreground">
|
||
请输入 <span class="font-mono font-semibold text-foreground">{{ CLEAR_KEYWORD }}</span> 以确认
|
||
</p>
|
||
<input
|
||
v-model="clearConfirmText"
|
||
class="w-full h-8 px-2.5 rounded-md border border-border bg-transparent
|
||
text-sm outline-none focus:border-destructive/60"
|
||
/>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" @click="clearOpen = false">取消</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
:disabled="clearBusy || clearConfirmText.trim() !== CLEAR_KEYWORD"
|
||
@click="submitClear"
|
||
>
|
||
<Loader2 v-if="clearBusy" class="size-3.5 mr-1.5 animate-spin" />
|
||
清空全部
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- ===== 导入 ===== -->
|
||
<Dialog v-model:open="importOpen">
|
||
<DialogContent class="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>导入 known_hosts</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
粘贴 <span class="font-mono">~/.ssh/known_hosts</span> 的内容,或从其它设备导出的记录。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Textarea
|
||
v-model="importText"
|
||
placeholder="example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA…"
|
||
class="font-mono text-xs min-h-[180px] resize-y"
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" @click="importOpen = false">取消</Button>
|
||
<Button size="sm" :disabled="importBusy" @click="submitImport">
|
||
<Loader2 v-if="importBusy" class="size-3.5 mr-1.5 animate-spin" />
|
||
导入
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|