停用 SSH 密钥兼容入口

停用历史 SSH 私钥生成、存储、读取和管理入口,强制服务器使用密码认证。
This commit is contained in:
xuans 2026-10-02 04:03:23 +08:00
parent 95450cb1eb
commit 7778aa5ced
10 changed files with 80 additions and 614 deletions

View File

@ -21,7 +21,6 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
"git.pcux.cn/xuans/Scert/backend/internal/sshkey"
)
type App struct {
@ -34,7 +33,6 @@ type App struct {
Tasks *repository.SQLTaskRepository
Deployment *deployment.Service
SecretStore *secretstore.Store
SSHKeys *sshkey.Service
DNSVerification *setup.DNSVerificationManager
Certificates *certificate.Service
Workflow *CertificateWorkflow
@ -140,7 +138,6 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
} else if deleted > 0 {
logger.Info("启动时已清理过期审计日志", "count", deleted)
}
sshKeys := sshkey.NewService(secretStore)
accounts := certificate.NewAccountRepository(secretStore)
certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts)
aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker()
@ -177,7 +174,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
}, management)
server = httpapi.New(httpapi.Dependencies{
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
@ -234,7 +231,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
Users: userRepo,
Tasks: taskRepo,
Deployment: deploymentService,
SecretStore: secretStore, SSHKeys: sshKeys, DNSVerification: dnsVerification, HTTP: server,
SecretStore: secretStore, DNSVerification: dnsVerification, HTTP: server,
Certificates: certificates,
Workflow: workflow,
Renewal: renewal,

View File

@ -27,8 +27,8 @@ func TestNewWiresApplicationServices(t *testing.T) {
if application.Deployment == nil || application.Certificates == nil || application.Workflow == nil || application.Renewal == nil {
t.Fatal("证书申请、部署或续期服务未完整注入应用实例")
}
if application.SecretStore == nil || application.SSHKeys == nil || application.DNSVerification == nil || application.HTTP == nil {
t.Fatal("敏感信息、SSH、DNS 或 HTTP 服务未完整注入应用实例")
if application.SecretStore == nil || application.DNSVerification == nil || application.HTTP == nil {
t.Fatal("敏感信息、DNS 或 HTTP 服务未完整注入应用实例")
}
}
func TestNewWithConfigPathRecoversInterruptedTasks(t *testing.T) {

View File

@ -65,9 +65,6 @@ func newServerAddCommand() *cobra.Command {
item.CreatedAt = now
item.UpdatedAt = now
item.ID = domain.NewID()
if _, err := service.SSHKeys.ValidateReference(cmd.Context(), item.PrivateKeyRef); err != nil {
return fmt.Errorf("SSH 私钥引用无效:%w", err)
}
if err := service.Servers.CreateServer(cmd.Context(), item); err != nil {
return err
}
@ -96,9 +93,6 @@ func newServerEditCommand() *cobra.Command {
item.UpdatedBy = ""
item.CreatedAt = existing.CreatedAt
item.UpdatedAt = time.Now().UTC()
if _, err := service.SSHKeys.ValidateReference(cmd.Context(), item.PrivateKeyRef); err != nil {
return fmt.Errorf("SSH 私钥引用无效:%w", err)
}
if err := service.Servers.UpdateServer(cmd.Context(), item); err != nil {
return err
}
@ -185,7 +179,7 @@ func newDomainServersSetCommand() *cobra.Command {
}
type serverFlags struct {
name, address, username, privateKeyRef, fingerprint string
name, address, username, fingerprint string
certificatePath, privateKeyPath, chainPath, webServer string
validationCommand, reloadCommand []string
port int
@ -196,7 +190,6 @@ func (f *serverFlags) bind(command *cobra.Command) {
command.Flags().StringVar(&f.address, "address", "", "服务器地址")
command.Flags().IntVar(&f.port, "port", 22, "SSH 端口")
command.Flags().StringVar(&f.username, "username", "", "SSH 用户")
command.Flags().StringVar(&f.privateKeyRef, "private-key-ref", "", "SSH 私钥的敏感信息引用")
command.Flags().StringVar(&f.fingerprint, "host-key-fingerprint", "", "SSH 主机 SHA256 指纹")
command.Flags().StringVar(&f.certificatePath, "certificate-path", deployment.DefaultCertificatePath, "远程证书文件路径模板")
command.Flags().StringVar(&f.privateKeyPath, "private-key-path", deployment.DefaultPrivateKeyPath, "远程私钥文件路径模板")
@ -204,7 +197,7 @@ func (f *serverFlags) bind(command *cobra.Command) {
command.Flags().StringVar(&f.webServer, "web-server", string(deployment.WebServerNginx), "Web 服务类型:nginx、caddy、apache、custom")
command.Flags().StringSliceVar(&f.validationCommand, "validation-command", nil, "自定义配置校验命令及参数")
command.Flags().StringSliceVar(&f.reloadCommand, "reload-command", nil, "自定义 reload 命令及参数")
for _, name := range []string{"name", "address", "username", "private-key-ref", "host-key-fingerprint"} {
for _, name := range []string{"name", "address", "username", "host-key-fingerprint"} {
_ = command.MarkFlagRequired(name)
}
}
@ -212,7 +205,7 @@ func (f *serverFlags) bind(command *cobra.Command) {
func (f serverFlags) server() deployment.Server {
return deployment.Server{
Name: f.name, Address: f.address, Port: f.port, Username: f.username,
PrivateKeyRef: f.privateKeyRef, HostKeyFingerprint: f.fingerprint,
AuthMethod: deployment.SSHAuthPassword, HostKeyFingerprint: f.fingerprint,
CertificatePath: f.certificatePath, PrivateKeyPath: f.privateKeyPath,
CertificateChainPath: f.chainPath, WebServer: deployment.WebServer(f.webServer),
ValidationCommand: normalizeCommand(f.validationCommand), ReloadCommand: normalizeCommand(f.reloadCommand),

View File

@ -2,117 +2,28 @@ package cli
import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"git.pcux.cn/xuans/Scert/backend/internal/sshkey"
)
func newSSHKeyCommand() *cobra.Command {
command := &cobra.Command{Use: "ssh-key", Short: "管理 SSH 部署私钥"}
command.AddCommand(newSSHKeyListCommand(), newSSHKeyAddCommand(), newSSHKeyDeleteCommand())
return command
return &cobra.Command{
Use: "ssh-key",
Short: "SSH 密钥登录功能已停用",
RunE: func(cmd *cobra.Command, args []string) error {
return errors.New("SSH 密钥登录功能已永久停用,仅支持 SSH 密码认证")
},
}
}
func newSSHKeyListCommand() *cobra.Command {
return &cobra.Command{Use: "list", Short: "查看 SSH 私钥元数据", RunE: func(cmd *cobra.Command, args []string) error {
service, err := openApplication(cmd)
if err != nil {
return err
}
defer service.Close()
items, err := service.SSHKeys.List(cmd.Context())
if err != nil {
return err
}
return printJSON(cmd, items)
}}
return newSSHKeyCommand()
}
func newSSHKeyAddCommand() *cobra.Command {
var name, filePath, passphraseFile string
command := &cobra.Command{Use: "add", Short: "从文件安全录入 SSH 私钥", RunE: func(cmd *cobra.Command, args []string) error {
privateKey, err := readLimitedFile(filePath, sshkey.MaxPrivateKeyBytes)
if err != nil {
return fmt.Errorf("读取 SSH 私钥文件失败:%w", err)
}
passphrase := ""
if strings.TrimSpace(passphraseFile) != "" {
content, err := readLimitedFile(passphraseFile, 4096)
if err != nil {
return fmt.Errorf("读取 SSH 私钥口令文件失败:%w", err)
}
passphrase = strings.TrimRight(content, "\r\n")
}
service, err := openApplication(cmd)
if err != nil {
return err
}
defer service.Close()
item, err := service.SSHKeys.Create(cmd.Context(), name, privateKey, passphrase)
if err != nil {
return err
}
return printJSON(cmd, item)
}}
command.Flags().StringVar(&name, "name", "", "SSH 私钥名称")
command.Flags().StringVar(&filePath, "file", "", "SSH 私钥文件路径")
command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "可选的私钥口令文件路径")
_ = command.MarkFlagRequired("name")
_ = command.MarkFlagRequired("file")
return command
return newSSHKeyCommand()
}
func newSSHKeyDeleteCommand() *cobra.Command {
var id string
command := &cobra.Command{Use: "delete", Short: "删除未被服务器使用的 SSH 私钥", RunE: func(cmd *cobra.Command, args []string) error {
service, err := openApplication(cmd)
if err != nil {
return err
}
defer service.Close()
reference, err := service.SSHKeys.Reference(id)
if err != nil {
return err
}
used, err := service.Servers.IsPrivateKeyRefUsed(cmd.Context(), reference)
if err != nil {
return err
}
if used {
return errors.New("SSH 私钥仍被部署服务器使用,不能删除")
}
if err := service.SSHKeys.Delete(cmd.Context(), id); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "SSH 私钥已删除:%s\n", id)
return nil
}}
command.Flags().StringVar(&id, "id", "", "SSH 私钥 ID")
_ = command.MarkFlagRequired("id")
return command
}
func readLimitedFile(path string, limit int) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("文件路径不能为空")
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, int64(limit)+1))
if err != nil {
return "", err
}
if len(content) > limit {
return "", fmt.Errorf("文件不能超过 %d 字节", limit)
}
return string(content), nil
return newSSHKeyCommand()
}

View File

@ -27,7 +27,6 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/sshkey"
)
type setupSecretWriter interface {
@ -53,7 +52,6 @@ type Dependencies struct {
ProjectResources repository.ProjectResourceReader
ProjectBindings repository.ProjectBindingRepository
ProjectDomains repository.ProjectDomainRepository
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
Secrets setupSecretWriter
@ -93,7 +91,6 @@ type Server struct {
projectResources repository.ProjectResourceReader
projectBindings repository.ProjectBindingRepository
projectDomains repository.ProjectDomainRepository
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
secrets setupSecretWriter
@ -161,7 +158,6 @@ func New(deps Dependencies) *Server {
projectResources: deps.ProjectResources,
projectBindings: deps.ProjectBindings,
projectDomains: deps.ProjectDomains,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
issueSetupCertificate: deps.IssueSetupCertificate,

View File

@ -1,104 +1,15 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
const maxSSHKeyRequestBytes = 2 * 1024 * 1024
type sshKeyRequest struct {
Name string `json:"name" binding:"required"`
PrivateKey string `json:"private_key" binding:"required"`
Passphrase string `json:"passphrase"`
ProjectID string `json:"project_id"`
}
import "github.com/gin-gonic/gin"
func (s *Server) listSSHKeys(c *gin.Context) {
if s.sshKeys == nil {
s.writeError(c, http.StatusNotImplemented, "SSH 私钥管理接口尚未配置", errors.New("SSH 私钥管理服务未配置"))
return
}
projectID := strings.TrimSpace(c.Query("project_id"))
if projectID == "" {
projectID = domain.DefaultProjectID
}
if !s.authorizeServerMutation(c, projectID) {
return
}
items, err := s.sshKeys.ListForProject(c.Request.Context(), projectID)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询 SSH 私钥失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
c.Status(404)
}
func (s *Server) createSSHKey(c *gin.Context) {
if s.sshKeys == nil {
s.writeError(c, http.StatusNotImplemented, "SSH 私钥管理接口尚未配置", errors.New("SSH 私钥管理服务未配置"))
return
}
var request sshKeyRequest
if err := bindJSON(c, &request, maxSSHKeyRequestBytes); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.writeError(c, http.StatusRequestEntityTooLarge, "SSH 私钥请求内容过大", err)
return
}
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
projectID := strings.TrimSpace(request.ProjectID)
if projectID == "" {
projectID = domain.DefaultProjectID
}
if !s.authorizeServerMutation(c, projectID) {
return
}
item, err := s.sshKeys.CreateForProject(c.Request.Context(), request.Name, request.PrivateKey, request.Passphrase, projectID)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥无效", err)
return
}
c.JSON(http.StatusCreated, item)
c.Status(404)
}
func (s *Server) deleteSSHKey(c *gin.Context) {
if s.sshKeys == nil || s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "SSH 私钥管理接口尚未配置", errors.New("SSH 私钥或服务器管理服务未配置"))
return
}
reference, err := s.sshKeys.Reference(c.Param("id"))
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥 ID 无效", err)
return
}
metadata, err := s.sshKeys.ValidateReference(c.Request.Context(), reference)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
return
}
if !s.authorizeServerMutation(c, metadata.ProjectID) {
return
}
used, err := s.servers.IsPrivateKeyRefUsed(c.Request.Context(), reference)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "检查 SSH 私钥占用状态失败", err)
return
}
if used {
s.writeError(c, http.StatusConflict, "SSH 私钥仍被部署服务器使用", errors.New("SSH 私钥引用正在使用"))
return
}
if err := s.sshKeys.Delete(c.Request.Context(), c.Param("id")); err != nil {
s.writeError(c, http.StatusNotFound, "SSH 私钥不存在", err)
return
}
c.Status(http.StatusNoContent)
c.Status(404)
}

View File

@ -2,27 +2,13 @@ package sshkey
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/ssh"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
)
const (
ReferencePrefix = "secret://ssh/"
secretKind = "ssh_private_key"
MaxPrivateKeyBytes = 1024 * 1024
maxNameLength = 128
maxPassphraseBytes = 4096
)
var ErrDisabled = errors.New("SSH 密钥登录功能已永久停用,仅支持 SSH 密码认证")
type Store interface {
Put(ctx context.Context, reference string, value map[string]string) error
@ -43,251 +29,49 @@ type Metadata struct {
}
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
func NewService(_ ...Store) *Service {
return &Service{}
}
func (s *Service) Create(ctx context.Context, name, privateKey, passphrase string) (Metadata, error) {
return s.CreateForProject(ctx, name, privateKey, passphrase, domain.DefaultProjectID)
func (s *Service) Create(context.Context, string, string, string) (Metadata, error) {
return Metadata{}, ErrDisabled
}
func (s *Service) CreateForProject(ctx context.Context, name, privateKey, passphrase, projectID string) (Metadata, error) {
if s == nil || s.store == nil {
return Metadata{}, errors.New("SSH 私钥存储未初始化")
}
projectID = normalizeProjectID(projectID)
name = strings.TrimSpace(name)
if name == "" {
return Metadata{}, errors.New("SSH 私钥名称不能为空")
}
if len([]rune(name)) > maxNameLength {
return Metadata{}, fmt.Errorf("SSH 私钥名称不能超过 %d 个字符", maxNameLength)
}
privateKey = strings.TrimSpace(privateKey)
if privateKey == "" {
return Metadata{}, errors.New("SSH 私钥内容不能为空")
}
if len(privateKey) > MaxPrivateKeyBytes {
return Metadata{}, fmt.Errorf("SSH 私钥内容不能超过 %d 字节", MaxPrivateKeyBytes)
}
if len(passphrase) > maxPassphraseBytes {
return Metadata{}, fmt.Errorf("SSH 私钥口令不能超过 %d 字节", maxPassphraseBytes)
}
signer, err := parsePrivateKey(privateKey, passphrase)
if err != nil {
return Metadata{}, err
}
id := domain.NewID()
reference := ReferencePrefix + id
payload := map[string]string{
"kind": secretKind,
"name": name,
"private_key": privateKey + "\n",
"passphrase": passphrase,
"algorithm": signer.PublicKey().Type(),
"fingerprint": ssh.FingerprintSHA256(signer.PublicKey()),
"project_id": projectID,
}
if err := s.store.Put(ctx, reference, payload); err != nil {
return Metadata{}, fmt.Errorf("保存 SSH 私钥失败:%w", err)
}
return Metadata{
ID: id, Name: name, Reference: reference, Algorithm: payload["algorithm"],
Fingerprint: payload["fingerprint"], UpdatedAt: time.Now().UTC(), ProjectID: projectID,
}, nil
func (s *Service) CreateForProject(context.Context, string, string, string, string) (Metadata, error) {
return Metadata{}, ErrDisabled
}
func (s *Service) CreateGenerated(ctx context.Context, name string) (Metadata, string, error) {
return s.CreateGeneratedForProject(ctx, name, domain.DefaultProjectID)
func (s *Service) CreateGenerated(context.Context, string) (Metadata, string, error) {
return Metadata{}, "", ErrDisabled
}
func (s *Service) CreateGeneratedForProject(ctx context.Context, name, projectID string) (Metadata, string, error) {
if s == nil || s.store == nil {
return Metadata{}, "", errors.New("SSH 私钥存储未初始化")
}
name = strings.TrimSpace(name)
if name == "" {
return Metadata{}, "", errors.New("SSH 私钥名称不能为空")
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return Metadata{}, "", fmt.Errorf("生成 SSH 密钥失败:%w", err)
}
privatePEM, err := ssh.MarshalPrivateKey(privateKey, "scert-deploy")
if err != nil {
return Metadata{}, "", fmt.Errorf("编码 SSH 私钥失败:%w", err)
}
metadata, err := s.CreateForProject(ctx, name, string(pem.EncodeToMemory(privatePEM)), "", projectID)
if err != nil {
return Metadata{}, "", err
}
sshPublicKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return Metadata{}, "", fmt.Errorf("编码 SSH 公钥失败:%w", err)
}
return metadata, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), nil
func (s *Service) CreateGeneratedForProject(context.Context, string, string) (Metadata, string, error) {
return Metadata{}, "", ErrDisabled
}
func (s *Service) AuthorizedPublicKey(ctx context.Context, reference string) (string, error) {
if s == nil || s.store == nil {
return "", errors.New("SSH 私钥存储未初始化")
}
metadata, err := s.metadata(ctx, strings.TrimSpace(reference), time.Time{}, true)
if err != nil {
return "", err
}
payload, err := s.store.Get(ctx, metadata.Reference)
if err != nil {
return "", fmt.Errorf("读取 SSH 私钥失败:%w", err)
}
signer, err := parsePrivateKey(payload["private_key"], payload["passphrase"])
if err != nil {
return "", err
}
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))), nil
func (s *Service) AuthorizedPublicKey(context.Context, string) (string, error) {
return "", ErrDisabled
}
func (s *Service) List(ctx context.Context) ([]Metadata, error) {
if s == nil || s.store == nil {
return nil, errors.New("SSH 私钥存储未初始化")
}
items, err := s.store.List(ctx, ReferencePrefix)
if err != nil {
return nil, fmt.Errorf("列出 SSH 私钥失败:%w", err)
}
result := make([]Metadata, 0, len(items))
for _, item := range items {
referenceID := strings.TrimPrefix(item.Reference, ReferencePrefix)
if strings.Contains(referenceID, "/") {
continue
}
metadata, err := s.metadata(ctx, item.Reference, item.UpdatedAt, false)
if err != nil {
return nil, err
}
result = append(result, metadata)
}
return result, nil
func (s *Service) List(context.Context) ([]Metadata, error) {
return nil, ErrDisabled
}
func (s *Service) ListForProject(ctx context.Context, projectID string) ([]Metadata, error) {
items, err := s.List(ctx)
if err != nil {
return nil, err
}
projectID = normalizeProjectID(projectID)
visible := make([]Metadata, 0, len(items))
for _, item := range items {
if normalizeProjectID(item.ProjectID) == projectID {
visible = append(visible, item)
}
}
return visible, nil
func (s *Service) ListForProject(context.Context, string) ([]Metadata, error) {
return nil, ErrDisabled
}
func (s *Service) ValidateReference(ctx context.Context, reference string) (Metadata, error) {
if s == nil || s.store == nil {
return Metadata{}, errors.New("SSH 私钥存储未初始化")
}
return s.metadata(ctx, strings.TrimSpace(reference), time.Time{}, true)
func (s *Service) ValidateReference(context.Context, string) (Metadata, error) {
return Metadata{}, ErrDisabled
}
func (s *Service) ValidateReferenceForProject(ctx context.Context, reference, projectID string) (Metadata, error) {
metadata, err := s.ValidateReference(ctx, reference)
if err != nil {
return Metadata{}, err
}
if normalizeProjectID(metadata.ProjectID) != normalizeProjectID(projectID) {
return Metadata{}, errors.New("SSH 私钥不属于当前项目")
}
return metadata, nil
func (s *Service) ValidateReferenceForProject(context.Context, string, string) (Metadata, error) {
return Metadata{}, ErrDisabled
}
func (s *Service) Delete(ctx context.Context, id string) error {
if s == nil || s.store == nil {
return errors.New("SSH 私钥存储未初始化")
}
reference, err := referenceFromID(id)
if err != nil {
return err
}
if _, err := s.metadata(ctx, reference, time.Time{}, false); err != nil {
return err
}
if err := s.store.Delete(ctx, reference); err != nil {
return fmt.Errorf("删除 SSH 私钥失败:%w", err)
}
return nil
func (s *Service) Delete(context.Context, string) error {
return ErrDisabled
}
func (s *Service) Reference(id string) (string, error) {
return referenceFromID(id)
}
func (s *Service) metadata(ctx context.Context, reference string, updatedAt time.Time, validateKey bool) (Metadata, error) {
if !strings.HasPrefix(reference, ReferencePrefix) {
return Metadata{}, errors.New("SSH 私钥引用格式无效")
}
id := strings.TrimPrefix(reference, ReferencePrefix)
if _, err := referenceFromID(id); err != nil {
return Metadata{}, err
}
payload, err := s.store.Get(ctx, reference)
if err != nil {
return Metadata{}, fmt.Errorf("读取 SSH 私钥失败:%w", err)
}
if payload["kind"] != secretKind || strings.TrimSpace(payload["private_key"]) == "" {
return Metadata{}, errors.New("敏感信息引用不是有效的 SSH 私钥")
}
algorithm := payload["algorithm"]
fingerprint := payload["fingerprint"]
if validateKey || algorithm == "" || fingerprint == "" {
signer, parseErr := parsePrivateKey(payload["private_key"], payload["passphrase"])
if parseErr != nil {
return Metadata{}, parseErr
}
algorithm = signer.PublicKey().Type()
fingerprint = ssh.FingerprintSHA256(signer.PublicKey())
}
return Metadata{
ID: id, Name: payload["name"], Reference: reference,
Algorithm: algorithm, Fingerprint: fingerprint, UpdatedAt: updatedAt,
ProjectID: normalizeProjectID(payload["project_id"]),
}, nil
}
func normalizeProjectID(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return domain.DefaultProjectID
}
return value
}
func parsePrivateKey(privateKey, passphrase string) (ssh.Signer, error) {
var (
signer ssh.Signer
err error
)
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
}
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) {
return nil, errors.New("SSH 私钥已加密,请提供私钥口令")
}
return nil, errors.New("SSH 私钥格式或口令无效")
}
return signer, nil
}
func referenceFromID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" || strings.ContainsAny(id, "/\\\x00\r\n") {
return "", errors.New("SSH 私钥 ID 无效")
}
return ReferencePrefix + id, nil
func (s *Service) Reference(string) (string, error) {
return "", ErrDisabled
}

View File

@ -0,0 +1,32 @@
package sshkey
import (
"errors"
"testing"
)
func TestServiceIsPermanentlyDisabled(t *testing.T) {
service := NewService()
if service == nil {
t.Fatal("停用兼容服务不能为空")
}
if _, err := service.Create(t.Context(), "test", "ignored", "ignored"); !errors.Is(err, ErrDisabled) {
t.Fatalf("创建操作未被永久停用:%v", err)
}
if _, _, err := service.CreateGenerated(t.Context(), "test"); !errors.Is(err, ErrDisabled) {
t.Fatalf("生成操作未被永久停用:%v", err)
}
if _, err := service.List(t.Context()); !errors.Is(err, ErrDisabled) {
t.Fatalf("列表操作未被永久停用:%v", err)
}
if _, err := service.ValidateReference(t.Context(), "ignored"); !errors.Is(err, ErrDisabled) {
t.Fatalf("引用校验操作未被永久停用:%v", err)
}
if err := service.Delete(t.Context(), "ignored"); !errors.Is(err, ErrDisabled) {
t.Fatalf("删除操作未被永久停用:%v", err)
}
if _, err := service.Reference("ignored"); !errors.Is(err, ErrDisabled) {
t.Fatalf("引用生成操作未被永久停用:%v", err)
}
}

View File

@ -1,3 +1,5 @@
//go:build legacy_ssh_key_tests
package sshkey
import (

View File

@ -112,145 +112,6 @@
border-top: 1px solid #dce6f1;
}
.server-modal .server-ssh-key-guide {
margin: 0;
padding: 14px;
border: 1px solid #c7daf1;
border-radius: 14px;
background: #f5f9ff;
}
.server-modal .server-ssh-key-guide .ssh-key-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.server-modal .server-ssh-key-guide .ssh-key-steps article {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
color: #294a73;
}
.server-modal .server-ssh-key-guide .ssh-key-steps b {
display: inline-grid;
place-items: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #5b91dc;
color: #fff;
}
.server-modal .server-ssh-key-guide .ssh-key-platforms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
margin-bottom: 8px;
padding: 4px;
border-radius: 10px;
background: #e7effa;
}
.server-modal .server-ssh-key-guide .platform-tab {
min-height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
color: #5d7390;
cursor: pointer;
font: inherit;
}
.server-modal .server-ssh-key-guide .platform-tab.active {
background: #fff;
color: #24579a;
box-shadow: 0 2px 8px rgb(42 80 125 / 12%);
}
.server-modal .server-ssh-key-guide .ssh-key-path-line,
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
display: grid;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
}
.server-modal .server-ssh-key-guide .ssh-key-path-line {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary > div {
display: grid;
gap: 4px;
min-width: 0;
}
.server-modal .server-ssh-key-guide code {
overflow-wrap: anywhere;
color: #294a73;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.server-modal .server-ssh-key-guide .ssh-command-card {
margin-bottom: 8px;
padding: 9px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
}
.server-modal .server-ssh-key-guide .ssh-command-heading,
.server-modal .server-ssh-key-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.server-modal .server-ssh-key-guide .ssh-command-heading {
margin-bottom: 5px;
color: #294a73;
font-weight: 700;
}
.server-modal .server-ssh-key-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
padding-top: 2px;
}
.server-modal .server-ssh-key-fields .field:nth-child(3) {
grid-column: 1 / -1;
}
.server-modal .server-ssh-key-fields textarea {
min-height: 96px;
}
.server-modal .server-ssh-key-actions {
padding-top: 8px;
border-top: 1px solid #d1e0f1;
}
.server-modal .server-command-section textarea {
min-height: 82px;
}
@ -268,9 +129,6 @@
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
@ -291,22 +149,4 @@
grid-column: 1 / -1;
}
.server-modal .server-ssh-key-guide .ssh-key-steps,
.server-modal .server-ssh-key-fields {
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-fields .field:nth-child(3) {
grid-column: auto;
}
.server-modal .server-ssh-key-guide .ssh-key-path-line {
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-heading,
.server-modal .server-ssh-key-actions {
align-items: stretch;
flex-direction: column;
}
}