添加新建虚拟机/重装系统 预设SSH密码以及KEY Auth功能

This commit is contained in:
MengMengCode
2026-06-09 23:25:19 +08:00
parent 9a826add87
commit 0c9f420474
12 changed files with 712 additions and 91 deletions
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
interface CreateContainerModalProps {
isOpen: boolean
@@ -35,6 +36,9 @@ const defaultForm: CreateContainerRequest = {
assign_ipv6: false,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '',
}
@@ -94,6 +98,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPorts = useMemo(() => {
if (!natEnabled) return []
@@ -148,6 +154,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
return
}
const boundedForm = normalizeCreateForm(form)
const wantsNAT = boundedForm.assign_nat !== false
@@ -254,6 +266,55 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
{linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-3 gap-2">
{([
['auto_password', '自动生成密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{sshAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={form.ssh_password || ''}
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{sshAuthMode === 'key' && (
<textarea
value={form.ssh_public_key || ''}
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3">
<input
@@ -620,6 +681,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
const wantsNAT = normalized.assign_nat !== false
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return {
...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
@@ -633,10 +696,22 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
}
}
function validateSSHAuthInputs(form: CreateContainerRequest) {
if (isWindowsTemplate(form.template_id)) return ''
const mode = form.ssh_auth_mode || 'auto_password'
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
if (mode !== 'auto_password') return '请选择登录方式'
return ''
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return {
+11 -1
View File
@@ -734,9 +734,17 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv6: true,
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '',
},
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
'POST /api/v1/containers/{id}/reinstall': {
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'PUT /api/v1/containers/{id}/traffic-limit': {
traffic_mode: 'total',
monthly_traffic_gb: 100,
@@ -788,6 +796,8 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv6: true,
ssh_auth_mode: 'key',
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
},
],
},
+81 -38
View File
@@ -78,6 +78,7 @@ import ResourceStatsPanel, {
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth'
const PUBLIC_HOST = window.location.hostname
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
@@ -135,6 +136,9 @@ export default function ContainerDetail() {
const [showReinstall, setShowReinstall] = useState(false)
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplate, setSelectedTemplate] = useState('')
const [reinstallAuthMode, setReinstallAuthMode] = useState<ReinstallSSHAuthMode>('keep')
const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('')
const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('')
const [reinstalling, setReinstalling] = useState(false)
const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
const [subUser, setSubUser] = useState<SubUser | null>(null)
@@ -413,6 +417,9 @@ export default function ContainerDetail() {
setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '')
}
setReinstallAuthMode('keep')
setReinstallPasswordDraft('')
setReinstallPublicKeyDraft('')
setShowReinstall(true)
} catch (err) {
console.error(err)
@@ -434,9 +441,28 @@ export default function ContainerDetail() {
const handleReinstall = async () => {
if (!containerIdentifier || !selectedTemplate) return
const linuxTemplate = !isWindowsTemplate(selectedTemplate)
if (linuxTemplate && reinstallAuthMode === 'password') {
const validationError = sshPasswordError(reinstallPasswordDraft.trim())
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
}
if (linuxTemplate && reinstallAuthMode === 'key') {
const validationError = sshPublicKeyError(reinstallPublicKeyDraft)
if (validationError) {
await dialog.alert('SSH Key 格式不正确', validationError)
return
}
}
setReinstalling(true)
try {
await reinstallContainer(containerIdentifier, selectedTemplate)
await reinstallContainer(containerIdentifier, selectedTemplate, linuxTemplate ? {
ssh_auth_mode: reinstallAuthMode,
ssh_password: reinstallAuthMode === 'password' ? reinstallPasswordDraft.trim() : '',
ssh_public_key: reinstallAuthMode === 'key' ? reinstallPublicKeyDraft.trim() : '',
} : undefined)
setShowReinstall(false)
setShowSSH(false)
setShowVNC(false)
@@ -450,23 +476,12 @@ export default function ContainerDetail() {
}
const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordDraft(generateSSHPassword())
setResetPasswordResult('')
}
const resetPasswordError = (password: string) => {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
return sshPasswordError(password)
}
const handleResetPassword = async () => {
@@ -740,6 +755,7 @@ export default function ContainerDetail() {
const isRunning = container.status === 'running'
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows')
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const isPolicyBlocked = !!container.policy_blocked
@@ -1484,6 +1500,55 @@ export default function ContainerDetail() {
))}
</select>
</Field>
{reinstallLinuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([
['keep', '保留当前密码'],
['auto_password', '生成新密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[ReinstallSSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setReinstallAuthMode(mode)}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${reinstallAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{reinstallAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={reinstallPasswordDraft}
onChange={(event) => setReinstallPasswordDraft(event.target.value)}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setReinstallPasswordDraft(generateSSHPassword())}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{reinstallAuthMode === 'key' && (
<textarea
value={reinstallPublicKeyDraft}
onChange={(event) => setReinstallPublicKeyDraft(event.target.value)}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className="flex justify-end gap-3">
<button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
@@ -2135,30 +2200,8 @@ function TrafficBar({ container }: { container: Container }) {
)
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
function isWindowsTemplate(templateID: string) {
return templateID.toLowerCase().includes('windows')
}
function getTemplateIcon(id: string): ReactNode {
+11 -2
View File
@@ -138,9 +138,18 @@ export interface CreateContainerRequest {
assign_ipv6: boolean
ipv6_count?: number
ipv6_addresses?: string[]
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
expires_at: string
}
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
}
export interface IPv6PrefixInfo {
interface: string
address: string
@@ -426,8 +435,8 @@ export const stopContainer = (id: ContainerIdentifier) =>
export const restartContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/restart`)
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const reinstallContainer = (id: ContainerIdentifier, templateId: string, options?: ReinstallContainerOptions) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId, ...(options || {}) })
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
+67
View File
@@ -0,0 +1,67 @@
export type SSHAuthMode = 'auto_password' | 'password' | 'key'
export type ReinstallSSHAuthMode = SSHAuthMode | 'keep'
const supportedKeyTypes = new Set([
'ssh-ed25519',
'ssh-rsa',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'sk-ssh-ed25519@openssh.com',
'sk-ecdsa-sha2-nistp256@openssh.com',
])
export function generateSSHPassword() {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
return secureShuffle(password.split('')).join('')
}
export function sshPasswordError(password: string) {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
}
export function sshPublicKeyError(publicKey: string) {
const key = publicKey.trim()
if (!key) return '请填写 SSH 公钥'
if (key.length > 8192) return 'SSH 公钥长度不能超过 8192 字符'
if (/[\r\n]/.test(key)) return 'SSH 公钥只能填写一行'
const parts = key.split(/\s+/)
if (parts.length < 2 || !supportedKeyTypes.has(parts[0])) return 'SSH 公钥格式不正确'
return ''
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}