feat: ssh user certificate auth

This commit is contained in:
Yoan.liu
2026-10-04 21:07:59 +08:00
parent 26c89b50e7
commit ebb9bada8b
11 changed files with 162 additions and 67 deletions
+16 -14
View File
@@ -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"),
+16 -14
View File
@@ -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"),
+17 -15
View File
@@ -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"`
}
+41 -8
View File
@@ -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
+8 -7
View File
@@ -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 {
@@ -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)
}
@@ -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)
}
+4 -1
View File
@@ -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)
}
@@ -81,6 +81,16 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean })
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "keyCertificate"]}
initialValue={initialValues.keyCertificate}
label={t("access.form.ssh_key_certificate.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key_certificate.tooltip") }}></span>}
rules={[formRule]}
>
<FileTextInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key_certificate.placeholder")} />
</Form.Item>
</Show>
<Form.Item label={t("access.form.ssh_jump_servers.label")}>
@@ -204,6 +214,17 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean })
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
<Form.Item
name={[index, "keyCertificate"]}
hidden={subfieldAuthMethod !== AUTH_METHOD_KEY}
label={t("access.form.ssh_key_certificate.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key_certificate.tooltip") }}></span>}
shouldUpdate
rules={[formRule]}
>
<FileTextInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key_certificate.placeholder")} />
</Form.Item>
</>
),
};
@@ -254,6 +275,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType<typeof getI18n> }) =
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<typeof getI18n> }) =
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;
}
+8
View File
@@ -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": {
+8
View File
@@ -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": {