Add password-bootstrap SSH setup for new connections

This commit is contained in:
liumangmang
2026-04-21 16:32:46 +08:00
parent 05b835eb02
commit 42836aa4c3
9 changed files with 862 additions and 215 deletions
+9 -6
View File
@@ -1,6 +1,7 @@
import client from './client'
export type AuthType = 'PASSWORD' | 'PRIVATE_KEY'
export type AuthType = 'PASSWORD' | 'PRIVATE_KEY'
export type ConnectionSetupMode = 'NONE' | 'PASSWORD_BOOTSTRAP'
export interface Connection {
id: number
@@ -18,11 +19,13 @@ export interface ConnectionCreateRequest {
host: string
port?: number
username: string
authType?: AuthType
password?: string
privateKey?: string
passphrase?: string
}
authType?: AuthType
password?: string
privateKey?: string
passphrase?: string
setupMode?: ConnectionSetupMode
bootstrapPassword?: string
}
export function listConnections() {
return client.get<Connection[]>('/connections')
+108 -63
View File
@@ -16,15 +16,18 @@ const emit = defineEmits<{
const name = ref('')
const host = ref('')
const port = ref(22)
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 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 passwordBootstrapEnabled = ref(false)
const bootstrapPassword = ref('')
const isEdit = computed(() => !!props.connection)
const isPasswordBootstrapMode = computed(() => !isEdit.value && passwordBootstrapEnabled.value)
const hostError = computed(() => {
const h = host.value.trim()
@@ -54,21 +57,25 @@ watch(
host.value = c.host
port.value = c.port
username.value = c.username
authType.value = c.authType
authType.value = c.authType
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
passwordBootstrapEnabled.value = false
bootstrapPassword.value = ''
} else {
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
passwordBootstrapEnabled.value = false
bootstrapPassword.value = ''
}
},
{ immediate: true }
@@ -147,36 +154,45 @@ async function handleSubmit() {
error.value = '请填写主机'
return
}
if (!username.value.trim()) {
error.value = '请填写用户名'
return
}
if (authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
error.value = '请填写密码'
return
}
if (authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
error.value = '请填写私钥'
return
}
if (!username.value.trim()) {
error.value = '请填写用户名'
return
}
if (isPasswordBootstrapMode.value && !bootstrapPassword.value) {
error.value = '请填写初始登录密码'
return
}
if (!isPasswordBootstrapMode.value && authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
error.value = '请填写密码'
return
}
if (!isPasswordBootstrapMode.value && authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
error.value = '请填写私钥'
return
}
loading.value = true
error.value = ''
const data: ConnectionCreateRequest = {
name: name.value.trim(),
host: host.value.trim(),
port: port.value,
username: username.value.trim(),
authType: authType.value,
}
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
}
try {
const data: ConnectionCreateRequest = {
name: name.value.trim(),
host: host.value.trim(),
port: port.value,
username: username.value.trim(),
}
if (isPasswordBootstrapMode.value) {
data.setupMode = 'PASSWORD_BOOTSTRAP'
data.bootstrapPassword = bootstrapPassword.value
} else {
data.authType = authType.value
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
}
}
try {
if (props.onSave) {
await props.onSave(data)
} else {
@@ -252,42 +268,71 @@ async function handleSubmit() {
<p v-if="portError" class="mt-1 text-xs text-red-400">{{ portError }}</p>
</div>
</div>
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">用户名</label>
<input
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">用户名</label>
<input
id="username"
v-model="username"
type="text"
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="root"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
placeholder="root"
/>
</div>
<div v-if="!isEdit" class="rounded-lg border border-cyan-900/70 bg-cyan-950/40 p-4 space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="passwordBootstrapEnabled" type="checkbox" class="mt-1 rounded border-slate-500 bg-slate-700 text-cyan-500" />
<div>
<p class="text-sm font-medium text-slate-100">一键免密配置</p>
<p class="mt-1 text-xs leading-5 text-slate-400">
使用一次初始密码为远端追加公钥创建成功后会保存为系统内的私钥连接
不会修改你本机的 <code>~/.ssh/config</code>
</p>
</div>
</label>
</div>
<div v-if="!isPasswordBootstrapMode">
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PASSWORD" class="rounded" />
<span class="text-slate-300">密码</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PRIVATE_KEY" class="rounded" />
<span class="text-slate-300">私钥</span>
</label>
</div>
</div>
<div v-if="authType === 'PASSWORD'">
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
</label>
</div>
</div>
<div v-if="isPasswordBootstrapMode" class="space-y-2 rounded-lg border border-emerald-900/70 bg-emerald-950/30 p-4">
<div>
<p class="text-sm font-medium text-slate-100">创建后将自动切换为私钥认证</p>
<p class="mt-1 text-xs leading-5 text-slate-400">
系统会生成一把新的 SSH 密钥先用密码登录并写入远端 <code>authorized_keys</code>验证成功后再保存连接
</p>
</div>
<div>
<label for="bootstrapPassword" class="block text-sm font-medium text-slate-300 mb-1">初始登录密码</label>
<input
id="bootstrapPassword"
v-model="bootstrapPassword"
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>
<div v-if="!isPasswordBootstrapMode && authType === 'PASSWORD'">
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<input
id="password"
v-model="password"
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"
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="!isPasswordBootstrapMode && authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
+1 -1
View File
@@ -252,7 +252,7 @@ async function handleSessionSubmit(data: ConnectionCreateRequest) {
await connectionsStore.createConnection(data)
// Tree node insertion is handled by useConnectionSync -> syncNewConnections.
// Avoid manual insertion here to prevent duplicate nodes.
toast.success('连接已创建')
toast.success(data.setupMode === 'PASSWORD_BOOTSTRAP' ? '连接已创建并完成免密配置' : '连接已创建')
showFirstRunGuide.value = false
}
closeSessionModal()