保存部署服务器密码认证支持与页面改造前检查点

This commit is contained in:
xuans 2026-09-25 13:28:47 +08:00
parent 24b9e7abdc
commit be6bc63102
10 changed files with 186 additions and 49 deletions

View File

@ -9,7 +9,7 @@ import (
"strings"
)
//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql
//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -38,7 +38,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 6
const latestMigrationVersion = 7
func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error {
path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version))
@ -131,6 +131,8 @@ func migrationName(version int) string {
return "users"
case 6:
return "server_validation"
case 7:
return "server_password_auth"
default:
return "unknown"
}

View File

@ -0,0 +1,2 @@
ALTER TABLE servers ADD COLUMN auth_method VARCHAR(32) NOT NULL DEFAULT 'private_key';
ALTER TABLE servers ADD COLUMN password_ref VARCHAR(255) NOT NULL DEFAULT '';

View File

@ -0,0 +1,2 @@
ALTER TABLE servers ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'private_key';
ALTER TABLE servers ADD COLUMN password_ref TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,2 @@
ALTER TABLE servers ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'private_key';
ALTER TABLE servers ADD COLUMN password_ref TEXT NOT NULL DEFAULT '';

View File

@ -12,6 +12,13 @@ import (
type WebServer string
type SSHAuthMethod string
const (
SSHAuthPrivateKey SSHAuthMethod = "private_key"
SSHAuthPassword SSHAuthMethod = "password"
)
const (
WebServerNginx WebServer = "nginx"
WebServerCaddy WebServer = "caddy"
@ -28,21 +35,24 @@ const (
var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`)
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Username string `json:"username"`
PrivateKeyRef string `json:"private_key_ref"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer WebServer `json:"web_server"`
ValidationCommand []string `json:"validation_command,omitempty"`
ReloadCommand []string `json:"reload_command,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Username string `json:"username"`
AuthMethod SSHAuthMethod `json:"auth_method"`
PrivateKeyRef string `json:"private_key_ref"`
PasswordRef string `json:"-"`
PasswordConfigured bool `json:"password_configured"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
CertificateChainPath string `json:"certificate_chain_path"`
WebServer WebServer `json:"web_server"`
ValidationCommand []string `json:"validation_command,omitempty"`
ReloadCommand []string `json:"reload_command,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s Server) Normalize() Server {
@ -50,7 +60,17 @@ func (s Server) Normalize() Server {
s.Name = strings.TrimSpace(s.Name)
s.Address = strings.TrimSpace(s.Address)
s.Username = strings.TrimSpace(s.Username)
s.AuthMethod = SSHAuthMethod(strings.ToLower(strings.TrimSpace(string(s.AuthMethod))))
s.PrivateKeyRef = strings.TrimSpace(s.PrivateKeyRef)
s.PasswordRef = strings.TrimSpace(s.PasswordRef)
s.PasswordConfigured = s.PasswordRef != ""
if s.AuthMethod == "" {
if s.PasswordRef != "" {
s.AuthMethod = SSHAuthPassword
} else {
s.AuthMethod = SSHAuthPrivateKey
}
}
s.HostKeyFingerprint = strings.TrimSpace(s.HostKeyFingerprint)
s.CertificatePath = strings.TrimSpace(s.CertificatePath)
s.PrivateKeyPath = strings.TrimSpace(s.PrivateKeyPath)
@ -164,8 +184,17 @@ func (s Server) Validate() error {
if s.Username == "" {
return errors.New("SSH 用户不能为空")
}
if s.PrivateKeyRef == "" {
return errors.New("SSH 私钥引用不能为空")
switch s.AuthMethod {
case SSHAuthPrivateKey:
if s.PrivateKeyRef == "" {
return errors.New("SSH \u79c1\u94a5\u5f15\u7528\u4e0d\u80fd\u4e3a\u7a7a")
}
case SSHAuthPassword:
if s.PasswordRef == "" {
return errors.New("SSH \u5bc6\u7801\u5f15\u7528\u4e0d\u80fd\u4e3a\u7a7a")
}
default:
return fmt.Errorf("SSH \u8ba4\u8bc1\u65b9\u5f0f\u65e0\u6548\uff1a%s", s.AuthMethod)
}
if s.HostKeyFingerprint == "" {
return errors.New("SSH 主机指纹不能为空")

View File

@ -33,23 +33,37 @@ func (f SSHFactory) Connect(ctx context.Context, server Server) (Client, error)
if f.Secrets == nil {
return nil, errors.New("SSH 敏感信息存储未初始化")
}
secrets, err := f.Secrets.Get(ctx, server.PrivateKeyRef)
secretReference := server.PrivateKeyRef
if server.AuthMethod == SSHAuthPassword {
secretReference = server.PasswordRef
}
secrets, err := f.Secrets.Get(ctx, secretReference)
if err != nil {
return nil, fmt.Errorf("读取 SSH 私钥失败:%w", err)
return nil, fmt.Errorf("读取 SSH 认证信息失败:%w", err)
}
privateKey := strings.TrimSpace(secrets["private_key"])
if privateKey == "" {
return nil, errors.New("SSH 私钥内容不能为空")
}
var signer ssh.Signer
passphrase := secrets["passphrase"]
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
var authMethods []ssh.AuthMethod
if server.AuthMethod == SSHAuthPassword {
password := secrets["password"]
if password == "" {
return nil, errors.New("SSH 密码不能为空")
}
authMethods = []ssh.AuthMethod{ssh.Password(password)}
} else {
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
}
if err != nil {
return nil, errors.New("解析 SSH 私钥失败:私钥格式或口令无效")
privateKey := strings.TrimSpace(secrets["private_key"])
if privateKey == "" {
return nil, errors.New("SSH 私钥内容不能为空")
}
var signer ssh.Signer
passphrase := secrets["passphrase"]
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
}
if err != nil {
return nil, errors.New("解析 SSH 私钥失败:私钥格式或口令无效")
}
authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
timeout := f.Timeout
if timeout <= 0 {
@ -61,7 +75,7 @@ func (f SSHFactory) Connect(ctx context.Context, server Server) (Client, error)
}
timeoutContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
sshClient, err := connectSSHClient(timeoutContext, server, signer, algorithms)
sshClient, err := connectSSHClient(timeoutContext, server, authMethods, algorithms)
if err != nil {
return nil, err
}
@ -108,7 +122,7 @@ func (f SSHFactory) secureHostKeyAlgorithms() ([]string, error) {
func connectSSHClient(
ctx context.Context,
server Server,
signer ssh.Signer,
authMethods []ssh.AuthMethod,
algorithms []string,
) (*ssh.Client, error) {
address := net.JoinHostPort(server.Address, fmt.Sprintf("%d", server.Port))
@ -131,7 +145,7 @@ func connectSSHClient(
hostKeyMismatch := ""
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
Auth: authMethods,
HostKeyAlgorithms: []string{algorithm},
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
fingerprint := ssh.FingerprintSHA256(key)

View File

@ -377,7 +377,9 @@ type serverRequest struct {
Address string `json:"address" binding:"required"`
Port int `json:"port"`
Username string `json:"username" binding:"required"`
PrivateKeyRef string `json:"private_key_ref" binding:"required"`
AuthMethod string `json:"auth_method"`
PrivateKeyRef string `json:"private_key_ref"`
Password string `json:"password"`
HostKeyFingerprint string `json:"host_key_fingerprint" binding:"required"`
CertificatePath string `json:"certificate_path"`
PrivateKeyPath string `json:"private_key_path"`
@ -700,11 +702,13 @@ func (s *Server) createServer(c *gin.Context) {
now := time.Now().UTC()
item.CreatedAt = now
item.UpdatedAt = now
if err := s.validateSSHKeyReference(c.Request.Context(), item.PrivateKeyRef); err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, nil)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 认证信息无效", err)
return
}
if err := s.servers.CreateServer(c.Request.Context(), item); err != nil {
cleanup()
s.writeError(c, serverErrorStatus(err), "保存服务器失败", err)
return
}
@ -735,14 +739,21 @@ func (s *Server) updateServer(c *gin.Context) {
}
item.CreatedAt = existing.CreatedAt
item.UpdatedAt = time.Now().UTC()
if err := s.validateSSHKeyReference(c.Request.Context(), item.PrivateKeyRef); err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, &existing)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 认证信息无效", err)
return
}
if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil {
cleanup()
s.writeError(c, serverErrorStatus(err), "更新服务器失败", err)
return
}
if existing.PasswordRef != "" && existing.PasswordRef != item.PasswordRef && s.secrets != nil {
if err := s.secrets.Delete(c.Request.Context(), existing.PasswordRef); err != nil && s.logger != nil {
s.logger.Warn("删除旧 SSH 密码失败", "error", err)
}
}
c.JSON(http.StatusOK, item)
}
@ -754,11 +765,52 @@ func (s *Server) validateSSHKeyReference(ctx context.Context, reference string)
return err
}
func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Server, password string, existing *deployment.Server) (deployment.Server, func(), error) {
item = item.Normalize()
cleanup := func() {}
if item.AuthMethod == deployment.SSHAuthPrivateKey {
if err := s.validateSSHKeyReference(ctx, item.PrivateKeyRef); err != nil {
return deployment.Server{}, cleanup, err
}
return item, cleanup, nil
}
if item.AuthMethod != deployment.SSHAuthPassword {
return deployment.Server{}, cleanup, errors.New("SSH \u8ba4\u8bc1\u65b9\u5f0f\u65e0\u6548")
}
if s.secrets == nil {
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u5b58\u50a8\u672a\u521d\u59cb\u5316")
}
if password == "" && existing != nil && existing.PasswordRef != "" {
item.PasswordRef = existing.PasswordRef
return item, cleanup, nil
}
if password == "" {
return deployment.Server{}, cleanup, errors.New("SSH \u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a")
}
item.PasswordRef = "secret://ssh/password/" + domain.NewID()
if err := s.secrets.Put(ctx, item.PasswordRef, map[string]string{"kind": "ssh_password", "password": password}); err != nil {
return deployment.Server{}, cleanup, fmt.Errorf("\u4fdd\u5b58 SSH \u5bc6\u7801\u5931\u8d25\uff1a%w", err)
}
cleanup = func() {
_ = s.secrets.Delete(context.Background(), item.PasswordRef)
}
return item, cleanup, nil
}
func (s *Server) deleteServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
item, err := s.servers.GetServerByID(c.Request.Context(), c.Param("id"))
if err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
return
}
if err := s.servers.DeleteServer(c.Request.Context(), c.Param("id")); err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
@ -767,6 +819,11 @@ func (s *Server) deleteServer(c *gin.Context) {
s.writeError(c, http.StatusInternalServerError, "删除服务器失败", err)
return
}
if item.PasswordRef != "" && s.secrets != nil {
if err := s.secrets.Delete(c.Request.Context(), item.PasswordRef); err != nil && s.logger != nil {
s.logger.Warn("删除 SSH 密码失败", "error", err)
}
}
c.Status(http.StatusNoContent)
}
@ -830,7 +887,7 @@ func (s *Server) replaceDomainServers(c *gin.Context) {
func serverFromRequest(request serverRequest) deployment.Server {
return deployment.Server{
ID: domain.NewID(), Name: request.Name, Address: request.Address, Port: request.Port,
Username: request.Username, PrivateKeyRef: request.PrivateKeyRef, HostKeyFingerprint: request.HostKeyFingerprint,
Username: request.Username, AuthMethod: deployment.SSHAuthMethod(request.AuthMethod), PrivateKeyRef: request.PrivateKeyRef, HostKeyFingerprint: request.HostKeyFingerprint,
CertificatePath: request.CertificatePath, PrivateKeyPath: request.PrivateKeyPath,
CertificateChainPath: request.CertificateChainPath, WebServer: deployment.WebServer(request.WebServer),
ValidationCommand: request.ValidationCommand, ReloadCommand: request.ReloadCommand,

View File

@ -58,8 +58,8 @@ func (r *SQLServerRepository) CreateServer(ctx context.Context, server deploymen
updatedAt = createdAt
}
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO servers
(id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.PrivateKeyRef,
(id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef,
server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt)
if err != nil {
return fmt.Errorf("保存服务器失败:%w", err)
@ -84,8 +84,8 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, private_key_ref = ?, host_key_fingerprint = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`),
server.Name, server.Address, server.Port, server.Username, server.PrivateKeyRef, server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID)
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`),
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID)
if err != nil {
return fmt.Errorf("更新服务器失败:%w", err)
}
@ -235,8 +235,8 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID
return nil
}
const serverSelect = `SELECT id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers`
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.private_key_ref, servers.host_key_fingerprint, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers`
const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers`
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers`
type rowScanner interface {
Scan(dest ...any) error
@ -245,14 +245,16 @@ type rowScanner interface {
func scanServer(row rowScanner) (deployment.Server, error) {
var server deployment.Server
var webServer string
var authMethod string
var validationCommandJSON string
var commandJSON string
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &server.PrivateKeyRef, &server.HostKeyFingerprint, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil {
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return deployment.Server{}, sql.ErrNoRows
}
return deployment.Server{}, fmt.Errorf("读取服务器失败:%w", err)
}
server.AuthMethod = deployment.SSHAuthMethod(authMethod)
server.WebServer = deployment.WebServer(webServer)
if err := json.Unmarshal([]byte(validationCommandJSON), &server.ValidationCommand); err != nil {
return deployment.Server{}, fmt.Errorf("解析配置校验命令失败:%w", err)

View File

@ -336,7 +336,10 @@ export function useDashboard() {
address: "",
port: 22,
username: "",
auth_method: "password",
private_key_ref: "",
password: "",
passwordConfigured: false,
host_key_fingerprint: "",
certificate_path: defaultCertificatePath,
private_key_path: defaultPrivateKeyPath,
@ -529,6 +532,7 @@ export function useDashboard() {
serverEditingId.value = item.id;
serverForm.value = {
...item,
password: "",
validation_command: (item.validation_command || []).join("\n"),
reload_command: (item.reload_command || []).join("\n"),
server_ids: [],
@ -643,6 +647,7 @@ export function useDashboard() {
address: serverForm.value.address,
port: Number(serverForm.value.port),
username: serverForm.value.username,
auth_method: serverForm.value.auth_method,
private_key_ref: serverForm.value.private_key_ref,
host_key_fingerprint: serverForm.value.host_key_fingerprint,
certificate_path: serverForm.value.certificate_path,
@ -652,6 +657,9 @@ export function useDashboard() {
validation_command: parseCommand(serverForm.value.validation_command),
reload_command: parseCommand(serverForm.value.reload_command),
};
if (serverForm.value.auth_method === "password") {
payload.password = serverForm.value.password;
}
const path = serverEditingId.value
? `/api/v1/servers/${serverEditingId.value}`
: "/api/v1/servers";

View File

@ -89,6 +89,25 @@ const {
required
type="text" /></label
><label class="field"
><span>认证方式</span
><select v-model="serverForm.auth_method">
<option value="password">SSH 密码</option>
<option value="private_key">SSH 私钥</option>
</select></label
>
</div>
<label v-if="serverForm.auth_method === 'password'" class="field">
<span>SSH 密码</span>
<input
v-model="serverForm.password"
type="password"
:required="!serverEditingId || !serverForm.passwordConfigured"
autocomplete="new-password"
placeholder="编辑时留空表示保留原密码"
/>
</label>
<div v-else class="form-grid">
<label class="field"
><span class="field-label-row"
><span>SSH 私钥</span
><button