Files
Thing/src/modules/terminal/components/KnownHostsPanel.vue
T
2026-09-18 18:28:13 +08:00

454 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>