增强 SSH/SFTP 稳定性并完善安全校验与前端交互

This commit is contained in:
liumangmang
2026-03-11 23:14:39 +08:00
parent 8845847ce2
commit 085123697e
34 changed files with 1433 additions and 605 deletions

View File

@@ -20,35 +20,59 @@ const username = ref('')
const authType = ref<AuthType>('PASSWORD')
const password = ref('')
const privateKey = ref('')
const privateKeyFileName = ref('')
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
const passphrase = ref('')
const isEdit = computed(() => !!props.connection)
const hostError = computed(() => {
const h = host.value.trim()
if (!h) return ''
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
if (ipv4Regex.test(h)) {
const parts = h.match(ipv4Regex)
if (parts && parts.slice(1, 5).every(p => parseInt(p) <= 255)) return ''
return 'IP地址格式无效'
}
if (hostnameRegex.test(h)) return ''
return '主机名格式无效'
})
const portError = computed(() => {
const p = port.value
if (p < 1 || p > 65535) return '端口号必须在1-65535之间'
return ''
})
watch(
() => props.connection,
(c) => {
() => props.connection,
(c) => {
if (c) {
name.value = c.name
host.value = c.host
port.value = c.port
username.value = c.username
authType.value = c.authType
password.value = ''
privateKey.value = ''
passphrase.value = ''
} else {
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
} else {
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
password.value = ''
privateKey.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
const error = ref('')
const loading = ref(false)
@@ -68,9 +92,53 @@ function handleBackdropMouseUp() {
function handleDialogMouseDown() {
backdropPressed.value = false
}
function normalizeKeyText(text: string) {
return text.replace(/\r\n?/g, '\n').trim() + '\n'
}
async function handlePrivateKeyFileChange(e: Event) {
error.value = ''
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// Keep it generous; OpenSSH keys are usually a few KB.
const MAX_SIZE = 256 * 1024
if (file.size > MAX_SIZE) {
error.value = '私钥文件过大(>256KB请检查是否选错文件'
input.value = ''
return
}
try {
const text = await file.text()
privateKey.value = normalizeKeyText(text)
privateKeyFileName.value = file.name
} catch {
error.value = '读取私钥文件失败'
input.value = ''
}
}
function clearPrivateKeyFile() {
privateKey.value = ''
privateKeyFileName.value = ''
if (privateKeyInputRef.value) privateKeyInputRef.value.value = ''
}
async function handleSubmit() {
error.value = ''
const hostErr = hostError.value
const portErr = portError.value
if (hostErr) {
error.value = hostErr
return
}
if (portErr) {
error.value = portErr
return
}
if (!name.value.trim()) {
error.value = '请填写名称'
return
@@ -169,6 +237,7 @@ async function handleSubmit() {
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="192.168.1.1"
/>
<p v-if="hostError" class="mt-1 text-xs text-red-400">{{ hostError }}</p>
</div>
<div>
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
@@ -180,6 +249,7 @@ async function handleSubmit() {
max="65535"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<p v-if="portError" class="mt-1 text-xs text-red-400">{{ portError }}</p>
</div>
</div>
<div>
@@ -217,25 +287,36 @@ async function handleSubmit() {
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<textarea
id="privateKey"
v-model="privateKey"
rows="6"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
></textarea>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<input
ref="privateKeyInputRef"
id="privateKey"
type="file"
accept=".pem,.key,.ppk,.txt,application/x-pem-file"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500 file:mr-4 file:rounded-md file:border-0 file:bg-slate-600 file:px-3 file:py-2 file:text-slate-100 hover:file:bg-slate-500"
@change="handlePrivateKeyFileChange"
/>
<div v-if="privateKeyFileName" class="flex items-center justify-between gap-3">
<p class="text-xs text-slate-400 truncate">已选择{{ privateKeyFileName }}</p>
<button
type="button"
class="text-xs text-slate-300 hover:text-slate-100 hover:underline"
@click="clearPrivateKeyFile"
>
清除
</button>
</div>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2 pt-2">
<button