停用 SSH 密钥兼容入口
停用历史 SSH 私钥生成、存储、读取和管理入口,强制服务器使用密码认证。
This commit is contained in:
parent
95450cb1eb
commit
7778aa5ced
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
32
backend/internal/sshkey/service_disabled_test.go
Normal file
32
backend/internal/sshkey/service_disabled_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
//go:build legacy_ssh_key_tests
|
||||
|
||||
package sshkey
|
||||
|
||||
import (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user