diff --git a/internal/certacme/certifiers/sp_ssh.go b/internal/certacme/certifiers/sp_ssh.go index d4c9c9465..16ad0646f 100644 --- a/internal/certacme/certifiers/sp_ssh.go +++ b/internal/certacme/certifiers/sp_ssh.go @@ -19,25 +19,27 @@ func init() { jumpServers := make([]chlgimpl.ServerConfig, len(credentials.JumpServers)) for i, jumpServer := range credentials.JumpServers { jumpServers[i] = chlgimpl.ServerConfig{ - SshHost: jumpServer.Host, - SshPort: jumpServer.Port, - SshAuthMethod: jumpServer.AuthMethod, - SshUsername: jumpServer.Username, - SshPassword: jumpServer.Password, - SshKey: jumpServer.Key, - SshKeyPassphrase: jumpServer.KeyPassphrase, + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + SshKeyCertificate: jumpServer.KeyCertificate, } } provider, err := chlgimpl.NewChallenger(&chlgimpl.ChallengerConfig{ ServerConfig: chlgimpl.ServerConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + SshKeyCertificate: credentials.KeyCertificate, }, JumpServers: jumpServers, UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), diff --git a/internal/certmgmt/deployers/sp_ssh.go b/internal/certmgmt/deployers/sp_ssh.go index 5a1e5d297..16d764ea1 100644 --- a/internal/certmgmt/deployers/sp_ssh.go +++ b/internal/certmgmt/deployers/sp_ssh.go @@ -19,25 +19,27 @@ func init() { jumpServers := make([]dplyimpl.ServerConfig, len(credentials.JumpServers)) for i, jumpServer := range credentials.JumpServers { jumpServers[i] = dplyimpl.ServerConfig{ - SshHost: jumpServer.Host, - SshPort: jumpServer.Port, - SshAuthMethod: jumpServer.AuthMethod, - SshUsername: jumpServer.Username, - SshPassword: jumpServer.Password, - SshKey: jumpServer.Key, - SshKeyPassphrase: jumpServer.KeyPassphrase, + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + SshKeyCertificate: jumpServer.KeyCertificate, } } provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{ ServerConfig: dplyimpl.ServerConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + SshKeyCertificate: credentials.KeyCertificate, }, JumpServers: jumpServers, UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), diff --git a/internal/domain/access.go b/internal/domain/access.go index ae5b67167..1ee3041ba 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -602,21 +602,23 @@ type AccessConfigForSpaceship struct { } type AccessConfigForSSH struct { - Host string `json:"host"` - Port int32 `json:"port"` - AuthMethod string `json:"authMethod"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - Key string `json:"key,omitempty"` - KeyPassphrase string `json:"keyPassphrase,omitempty"` - JumpServers []struct { - Host string `json:"host"` - Port int32 `json:"port"` - AuthMethod string `json:"authMethod"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - Key string `json:"key,omitempty"` - KeyPassphrase string `json:"keyPassphrase,omitempty"` + Host string `json:"host"` + Port int32 `json:"port"` + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + KeyPassphrase string `json:"keyPassphrase,omitempty"` + KeyCertificate string `json:"keyCertificate,omitempty"` + JumpServers []struct { + Host string `json:"host"` + Port int32 `json:"port"` + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + KeyPassphrase string `json:"keyPassphrase,omitempty"` + KeyCertificate string `json:"keyCertificate,omitempty"` } `json:"jumpServers,omitempty"` } diff --git a/internal/tools/ssh/client.go b/internal/tools/ssh/client.go index abaf025b1..93a998a10 100644 --- a/internal/tools/ssh/client.go +++ b/internal/tools/ssh/client.go @@ -191,19 +191,14 @@ func createSshClientWithConn(config *ServerConfig, conn net.Conn) (*ssh.Client, key := config.Key keyPassphrase := config.KeyPassphrase + keyCertificate := config.KeyCertificate - var signer ssh.Signer - var err error - if keyPassphrase != "" { - signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) - } else { - signer, err = ssh.ParsePrivateKey([]byte(key)) - } + signers, err := createSigners(key, keyPassphrase, keyCertificate) if err != nil { return nil, err } - authMethods = append(authMethods, ssh.PublicKeys(signer)) + authMethods = append(authMethods, ssh.PublicKeys(signers...)) } default: @@ -223,6 +218,44 @@ func createSshClientWithConn(config *ServerConfig, conn net.Conn) (*ssh.Client, return ssh.NewClient(sshConn, chans, reqs), nil } +func createSigners(key, keyPassphrase, keyCertificate string) ([]ssh.Signer, error) { + var signer ssh.Signer + var err error + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + if err != nil { + return nil, err + } + + keyCertificate = strings.TrimSpace(keyCertificate) + if keyCertificate == "" { + return []ssh.Signer{signer}, nil + } + + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyCertificate)) + if err != nil { + return nil, fmt.Errorf("ssh: failed to parse key certificate: %w", err) + } + + cert, ok := parsed.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("ssh: key certificate is not an OpenSSH certificate") + } + if cert.CertType != ssh.UserCert { + return nil, fmt.Errorf("ssh: key certificate is not a user certificate") + } + + certSigner, err := ssh.NewCertSigner(cert, signer) + if err != nil { + return nil, fmt.Errorf("ssh: failed to create key certificate signer: %w", err) + } + + return []ssh.Signer{certSigner, signer}, nil +} + func resolveAddr(host string, port int) string { if port == 0 { port = defaultPort diff --git a/internal/tools/ssh/config.go b/internal/tools/ssh/config.go index 4f62bc764..2645b266a 100644 --- a/internal/tools/ssh/config.go +++ b/internal/tools/ssh/config.go @@ -7,13 +7,14 @@ const ( ) type ServerConfig struct { - Host string - Port int - AuthMethod AuthMethodType - Username string - Password string - Key string - KeyPassphrase string + Host string + Port int + AuthMethod AuthMethodType + Username string + Password string + Key string + KeyPassphrase string + KeyCertificate string } type Config struct { diff --git a/pkg/core/certifier/challengers/http01/ssh/internal/ssh.go b/pkg/core/certifier/challengers/http01/ssh/internal/ssh.go index d52218f55..bfa6f941b 100644 --- a/pkg/core/certifier/challengers/http01/ssh/internal/ssh.go +++ b/pkg/core/certifier/challengers/http01/ssh/internal/ssh.go @@ -104,6 +104,7 @@ func (p *HTTPProvider) createSshClient() (*ssh.Client, error) { clientCfg.Password = p.config.Password clientCfg.Key = p.config.Key clientCfg.KeyPassphrase = p.config.KeyPassphrase + clientCfg.KeyCertificate = p.config.KeyCertificate for _, jumpServer := range p.config.JumpServers { jumpServerCfg := ssh.NewServerConfig() jumpServerCfg.Host = jumpServer.Host @@ -113,6 +114,7 @@ func (p *HTTPProvider) createSshClient() (*ssh.Client, error) { jumpServerCfg.Password = jumpServer.Password jumpServerCfg.Key = jumpServer.Key jumpServerCfg.KeyPassphrase = jumpServer.KeyPassphrase + jumpServerCfg.KeyCertificate = jumpServer.KeyCertificate clientCfg.JumpServers = append(clientCfg.JumpServers, *jumpServerCfg) } diff --git a/pkg/core/certifier/challengers/http01/ssh/ssh.go b/pkg/core/certifier/challengers/http01/ssh/ssh.go index 18cfd5003..0252cda9b 100644 --- a/pkg/core/certifier/challengers/http01/ssh/ssh.go +++ b/pkg/core/certifier/challengers/http01/ssh/ssh.go @@ -26,7 +26,8 @@ type ServerConfig struct { // SSH 登录私钥。 SshKey string `json:"sshKey,omitempty"` // SSH 登录私钥口令。 - SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + SshKeyCertificate string `json:"sshKeyCertificate,omitempty"` } type ChallengerConfig struct { @@ -53,15 +54,17 @@ func NewChallenger(config *ChallengerConfig) (core.ACMEChallenger, error) { providerConfig.Password = config.SshPassword providerConfig.Key = config.SshKey providerConfig.KeyPassphrase = config.SshKeyPassphrase + providerConfig.KeyCertificate = config.SshKeyCertificate for _, jumpServer := range config.JumpServers { jumpServerCfg := ssh.ServerConfig{ - Host: jumpServer.SshHost, - Port: int(jumpServer.SshPort), - AuthMethod: ssh.AuthMethodType(jumpServer.SshAuthMethod), - Username: jumpServer.SshUsername, - Password: jumpServer.SshPassword, - Key: jumpServer.SshKey, - KeyPassphrase: jumpServer.SshKeyPassphrase, + Host: jumpServer.SshHost, + Port: int(jumpServer.SshPort), + AuthMethod: ssh.AuthMethodType(jumpServer.SshAuthMethod), + Username: jumpServer.SshUsername, + Password: jumpServer.SshPassword, + Key: jumpServer.SshKey, + KeyPassphrase: jumpServer.SshKeyPassphrase, + KeyCertificate: jumpServer.SshKeyCertificate, } providerConfig.JumpServers = append(providerConfig.JumpServers, jumpServerCfg) } diff --git a/pkg/core/deployer/providers/ssh/ssh.go b/pkg/core/deployer/providers/ssh/ssh.go index 9b19d1483..391dbb69a 100644 --- a/pkg/core/deployer/providers/ssh/ssh.go +++ b/pkg/core/deployer/providers/ssh/ssh.go @@ -36,7 +36,8 @@ type ServerConfig struct { // SSH 登录私钥。 SshKey string `json:"sshKey,omitempty"` // SSH 登录私钥口令。 - SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + SshKeyCertificate string `json:"sshKeyCertificate,omitempty"` } type DeployerConfig struct { @@ -257,6 +258,7 @@ func createSshClient(config DeployerConfig) (*ssh.Client, error) { clientCfg.Password = config.SshPassword clientCfg.Key = config.SshKey clientCfg.KeyPassphrase = config.SshKeyPassphrase + clientCfg.KeyCertificate = config.SshKeyCertificate for _, jumpServer := range config.JumpServers { jumpServerCfg := ssh.NewServerConfig() jumpServerCfg.Host = jumpServer.SshHost @@ -266,6 +268,7 @@ func createSshClient(config DeployerConfig) (*ssh.Client, error) { jumpServerCfg.Password = jumpServer.SshPassword jumpServerCfg.Key = jumpServer.SshKey jumpServerCfg.KeyPassphrase = jumpServer.SshKeyPassphrase + jumpServerCfg.KeyCertificate = jumpServer.SshKeyCertificate clientCfg.JumpServers = append(clientCfg.JumpServers, *jumpServerCfg) } diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx index 6992d35c5..4c59ec02c 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx @@ -81,6 +81,16 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) > + + } + rules={[formRule]} + > + + @@ -204,6 +214,17 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) > + + ), }; @@ -254,6 +275,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) = password: z.string().nullish(), key: z.string().max(20480).nullish(), keyPassphrase: z.string().nullish(), + keyCertificate: z.string().max(20480).nullish(), }) .superRefine((values, ctx) => { switch (values.authMethod) { @@ -282,6 +304,15 @@ const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) = path: ["key"], }); } + + const keyCertificate = values.keyCertificate?.trim() ?? ""; + if (keyCertificate !== "" && !keyCertificate.includes("-cert-v01@openssh.com")) { + ctx.addIssue({ + code: "custom", + message: t("access.form.ssh_key_certificate.errmsg.invalid"), + path: ["keyCertificate"], + }); + } } break; } diff --git a/ui/src/i18n/resources/en/nls.access.json b/ui/src/i18n/resources/en/nls.access.json index 69f57558f..16b6cfcbd 100644 --- a/ui/src/i18n/resources/en/nls.access.json +++ b/ui/src/i18n/resources/en/nls.access.json @@ -1433,6 +1433,14 @@ "label": "SSH key passphrase (Optional)", "placeholder": "Please enter SSH key passphrase" }, + "ssh_key_certificate": { + "label": "SSH key certificate (Optional)", + "placeholder": "Please enter SSH key certificate\r\n\r\nssh-ed25519-cert-v01@openssh.com AAAA...", + "tooltip": "The contents of an OpenSSH user certificate file (such as id_ed25519-cert.pub) signed by the user CA trusted by the target host. If set, it is offered before the bare SSH key during authentication.", + "errmsg": { + "invalid": "Invalid SSH key certificate" + } + }, "ssh_jump_servers": { "label": "Jump servers (Optional)", "errmsg": { diff --git a/ui/src/i18n/resources/zh/nls.access.json b/ui/src/i18n/resources/zh/nls.access.json index d54a6769a..2d1c74d1f 100644 --- a/ui/src/i18n/resources/zh/nls.access.json +++ b/ui/src/i18n/resources/zh/nls.access.json @@ -1433,6 +1433,14 @@ "label": "SSH 密钥口令(可选)", "placeholder": "请输入 SSH 密钥口令" }, + "ssh_key_certificate": { + "label": "SSH 密钥证书(可选)", + "placeholder": "请输入 SSH 密钥证书\r\n\r\nssh-ed25519-cert-v01@openssh.com AAAA...", + "tooltip": "由目标主机信任的用户 CA 签发的 OpenSSH 用户证书文件内容(如 id_ed25519-cert.pub)。设置后将优先于裸私钥进行认证。", + "errmsg": { + "invalid": "无效的 SSH 密钥证书" + } + }, "ssh_jump_servers": { "label": "跳板机(可选)", "errmsg": {