fix(ssh): validate imported key pairs and cancel stale file reads (#13991)

This commit is contained in:
ssongliu
2026-10-08 14:06:40 +08:00
committed by GitHub
parent 8f1be42b6b
commit b962144e7d
28 changed files with 280 additions and 67 deletions
+2
View File
@@ -83,6 +83,7 @@ func (b *BaseApi) CreateRootCert(c *gin.Context) {
}
if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err)
return
}
if err := sshService.CreateRootCert(req); err != nil {
helper.InternalServer(c, err)
@@ -107,6 +108,7 @@ func (b *BaseApi) EditRootCert(c *gin.Context) {
}
if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err)
return
}
if err := sshService.EditRootCert(req); err != nil {
helper.InternalServer(c, err)
+1 -1
View File
@@ -25,7 +25,7 @@ type RootCertOperate struct {
ID uint `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
EncryptionMode string `json:"encryptionMode" validate:"required,oneof=rsa ed25519 ecdsa dsa"`
EncryptionMode string `json:"encryptionMode"`
PassPhrase string `json:"passPhrase"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"`
+72 -11
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net"
@@ -37,6 +38,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
)
const sshPath = "/etc/ssh/sshd_config"
@@ -438,15 +440,33 @@ func (u *SSHService) SyncRootCert() error {
global.LOG.Errorf("read pubic key of %s for sync failed, err: %v", item, err)
continue
}
cert.EncryptionMode = loadEncryptioMode(string(pubItem))
publicKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubItem)
if err != nil || len(bytes.TrimSpace(rest)) != 0 {
continue
}
cert.EncryptionMode = loadSSHKeyType(publicKey)
if cert.EncryptionMode == "" {
continue
}
rootCerts = append(rootCerts, cert)
}
return hostRepo.SyncCert(rootCerts)
}
func (u *SSHService) CreateRootCert(req dto.RootCertOperate) error {
if cmd.CheckIllegal(req.EncryptionMode, req.PassPhrase) {
return buserr.New("ErrCmdIllegal")
switch req.Mode {
case "generate":
switch req.EncryptionMode {
case "rsa", "ed25519", "ecdsa", "dsa":
default:
return buserr.WithName("ErrNotSupportType", req.EncryptionMode)
}
case "input", "import":
if err := validateSSHKeyPair(&req); err != nil {
return err
}
default:
return buserr.WithName("ErrNotSupportType", req.Mode)
}
certItem, _ := hostRepo.GetCert(repo.WithByName(req.Name))
if certItem.ID != 0 {
@@ -543,6 +563,9 @@ func (u *SSHService) CreateRootCert(req dto.RootCertOperate) error {
}
func (u *SSHService) EditRootCert(req dto.RootCertOperate) error {
if err := validateSSHKeyPair(&req); err != nil {
return err
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("load current user failed, err: %v", err)
@@ -1681,22 +1704,60 @@ func loadDate(currentYear int, DateStr string, nyc *time.Location) time.Time {
return itemDate
}
func loadEncryptioMode(content string) string {
if strings.HasPrefix(content, "ssh-rsa") {
func loadSSHKeyType(publicKey ssh.PublicKey) string {
switch publicKey.Type() {
case ssh.KeyAlgoRSA:
return "rsa"
}
if strings.HasPrefix(content, "ssh-ed25519") {
case ssh.KeyAlgoED25519:
return "ed25519"
}
if strings.HasPrefix(content, "ssh-ecdsa") {
case ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521:
return "ecdsa"
}
if strings.HasPrefix(content, "ssh-dsa") {
case ssh.KeyAlgoDSA:
return "dsa"
}
return ""
}
func validateSSHKeyPair(req *dto.RootCertOperate) error {
req.PublicKey = strings.TrimSpace(req.PublicKey)
req.PrivateKey = strings.TrimSpace(req.PrivateKey)
if req.PublicKey == "" || strings.ContainsAny(req.PublicKey, "\r\n") {
return buserr.New("ErrSSHPublicKey")
}
publicKey, comment, options, rest, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
if err != nil || len(options) != 0 || len(bytes.TrimSpace(rest)) != 0 {
return buserr.New("ErrSSHPublicKey")
}
req.EncryptionMode = loadSSHKeyType(publicKey)
if req.EncryptionMode == "" {
return buserr.WithName("ErrNotSupportType", publicKey.Type())
}
block, rest := pem.Decode([]byte(req.PrivateKey))
if !strings.HasPrefix(req.PrivateKey, "-----BEGIN ") || block == nil || len(bytes.TrimSpace(rest)) != 0 {
return buserr.New("ErrSSHPrivateKey")
}
signer, err := ssh.ParsePrivateKey([]byte(req.PrivateKey))
var missingPassphrase *ssh.PassphraseMissingError
if errors.As(err, &missingPassphrase) {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(req.PrivateKey), []byte(req.PassPhrase))
} else if err == nil {
req.PassPhrase = ""
}
if err != nil {
return buserr.New("ErrSSHPrivateKey")
}
if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) {
return buserr.New("ErrSSHKeyMismatch")
}
req.PublicKey = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey)))
if comment != "" {
req.PublicKey += " " + comment
}
req.PublicKey += "\n"
req.PrivateKey += "\n"
return nil
}
func updateLocalConn(newPort uint) error {
conn, _ := settingRepo.GetValueByKey("LocalSSHConn")
if len(conn) == 0 {
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Target filename: {{ .name }}'
ErrFirewallBackendCleanupRequired: "The current backend {{ .current }} still contains 1Panel rules. Clean it up before switching to {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "IPv4 forwarding is disabled. Set net.ipv4.ip_forward=1 before using Docker's firewall backend."
ErrFirewallRuleSavedApplyFailed: "The new rule configuration was saved, but applying it to the firewall failed. Retry by synchronizing: {{ .detail }}"
ErrSSHPublicKey: "Enter a valid SSH public key"
ErrSSHPrivateKey: "Invalid SSH private key or incorrect passphrase; encrypted keys require the original passphrase"
ErrSSHKeyMismatch: "The SSH public and private keys do not match"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Nombre del archivo de destino: {{ .name }}'
ErrFirewallBackendCleanupRequired: "El backend actual {{ .current }} aún contiene reglas de 1Panel. Elimínelas antes de cambiar a {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "El reenvío IPv4 está desactivado. Configure net.ipv4.ip_forward=1 antes de usar el backend de cortafuegos de Docker."
ErrFirewallRuleSavedApplyFailed: "Se guardó la configuración de la nueva regla, pero no se pudo aplicar al cortafuegos. Vuelva a intentarlo mediante la sincronización: {{ .detail }}"
ErrSSHPublicKey: "Introduce una clave pública SSH válida"
ErrSSHPrivateKey: "Clave privada SSH no válida o contraseña incorrecta; las claves cifradas requieren la contraseña original"
ErrSSHKeyMismatch: "Las claves pública y privada SSH no coinciden"
+4
View File
@@ -732,3 +732,7 @@ FileTaskRename: 'نام فایل مقصد: {{ .name }}'
ErrFirewallBackendCleanupRequired: "بک‌اند فعلی {{ .current }} هنوز شامل قوانین 1Panel است. پیش از تغییر به {{ .target }} آن‌ها را پاک کنید."
ErrDockerIPv4ForwardingDisabled: "ارسال IPv4 غیرفعال است. پیش از استفاده از بک‌اند فایروال Docker، مقدار net.ipv4.ip_forward=1 را تنظیم کنید."
ErrFirewallRuleSavedApplyFailed: "پیکربندی قانون جدید ذخیره شد، اما اعمال آن در دیوار آتش ناموفق بود. با همگام‌سازی دوباره تلاش کنید: {{ .detail }}"
ErrSSHPublicKey: "یک کلید عمومی SSH معتبر وارد کنید"
ErrSSHPrivateKey: "کلید خصوصی SSH نامعتبر یا عبارت عبور نادرست است؛ کلیدهای رمزگذاری‌شده به عبارت عبور اصلی نیاز دارند"
ErrSSHKeyMismatch: "کلید عمومی و خصوصی SSH مطابقت ندارند"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: '保存先ファイル名:{{ .name }}'
ErrFirewallBackendCleanupRequired: "現在のバックエンド {{ .current }} に 1Panel ルールが残っています。{{ .target }} に切り替える前に削除してください。"
ErrDockerIPv4ForwardingDisabled: "IPv4 転送が無効です。Docker のファイアウォールバックエンドを使用する前に net.ipv4.ip_forward=1 を設定してください。"
ErrFirewallRuleSavedApplyFailed: "新しいルール設定は保存されましたが、ファイアウォールへの適用に失敗しました。同期で再試行してください:{{ .detail }}"
ErrSSHPublicKey: "有効な SSH 公開鍵を入力してください"
ErrSSHPrivateKey: "SSH 秘密鍵の形式が無効か、パスフレーズが間違っています。暗号化された鍵には元のパスフレーズが必要です"
ErrSSHKeyMismatch: "SSH 公開鍵と秘密鍵が一致しません"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: '대상 파일 이름: {{ .name }}'
ErrFirewallBackendCleanupRequired: "현재 백엔드 {{ .current }}에 1Panel 규칙이 남아 있습니다. {{ .target }}로 전환하기 전에 정리하세요."
ErrDockerIPv4ForwardingDisabled: "IPv4 전달이 비활성화되어 있습니다. Docker 방화벽 백엔드를 사용하기 전에 net.ipv4.ip_forward=1을 설정하세요."
ErrFirewallRuleSavedApplyFailed: "새 규칙 설정이 저장되었지만 방화벽에 적용하지 못했습니다. 동기화하여 다시 시도하세요: {{ .detail }}"
ErrSSHPublicKey: "유효한 SSH 공개 키를 입력하세요"
ErrSSHPrivateKey: "SSH 개인 키 형식이 잘못되었거나 암호가 틀립니다. 암호화된 키에는 원래 암호가 필요합니다"
ErrSSHKeyMismatch: "SSH 공개 키와 개인 키가 일치하지 않습니다"
+4
View File
@@ -722,3 +722,7 @@ FileTaskRename: 'ຊື່ໄຟລ໌ປາຍທາງ: {{ .name }}'
ErrFirewallBackendCleanupRequired: "ແບັກເອນປັດຈຸບັນ {{ .current }} ຍັງມີກົດຂອງ 1Panel. ກະລຸນາລຶບອອກກ່ອນປ່ຽນໄປ {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "ການສົ່ງຕໍ່ IPv4 ຖືກປິດ. ກະລຸນາຕັ້ງ net.ipv4.ip_forward=1 ກ່ອນໃຊ້ແບັກເອນໄຟວໍຂອງ Docker."
ErrFirewallRuleSavedApplyFailed: "ບັນທຶກການຕັ້ງຄ່າກົດໃໝ່ແລ້ວ ແຕ່ນຳໃຊ້ກັບໄຟວໍບໍ່ສຳເລັດ. ລອງອີກຄັ້ງດ້ວຍການຊິງຂໍ້ມູນ: {{ .detail }}"
ErrSSHPublicKey: "ກະລຸນາປ້ອນຄີສາທາລະນະ SSH ທີ່ຖືກຕ້ອງ"
ErrSSHPrivateKey: "ຄີສ່ວນຕົວ SSH ບໍ່ຖືກຕ້ອງ ຫຼື ລະຫັດຜ່ານຜິດ; ຄີທີ່ເຂົ້າລະຫັດຕ້ອງໃຊ້ລະຫັດຜ່ານເດີມ"
ErrSSHKeyMismatch: "ຄີສາທາລະນະ ແລະ ຄີສ່ວນຕົວ SSH ບໍ່ກົງກັນ"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Nama fail sasaran: {{ .name }}'
ErrFirewallBackendCleanupRequired: "Bahagian belakang semasa {{ .current }} masih mengandungi peraturan 1Panel. Buangkannya sebelum beralih kepada {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "Pemajuan IPv4 dilumpuhkan. Tetapkan net.ipv4.ip_forward=1 sebelum menggunakan bahagian belakang tembok api Docker."
ErrFirewallRuleSavedApplyFailed: "Konfigurasi peraturan baharu telah disimpan, tetapi gagal digunakan pada tembok api. Cuba lagi melalui penyegerakan: {{ .detail }}"
ErrSSHPublicKey: "Masukkan kunci awam SSH yang sah"
ErrSSHPrivateKey: "Kunci peribadi SSH tidak sah atau frasa laluan salah; kunci yang disulitkan memerlukan frasa laluan asal"
ErrSSHKeyMismatch: "Kunci awam dan peribadi SSH tidak sepadan"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Nome do arquivo de destino: {{ .name }}'
ErrFirewallBackendCleanupRequired: "O backend atual {{ .current }} ainda contém regras do 1Panel. Remova-as antes de mudar para {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "O encaminhamento IPv4 está desativado. Defina net.ipv4.ip_forward=1 antes de usar o backend de firewall do Docker."
ErrFirewallRuleSavedApplyFailed: "A configuração da nova regra foi salva, mas não pôde ser aplicada ao firewall. Tente novamente por meio da sincronização: {{ .detail }}"
ErrSSHPublicKey: "Informe uma chave pública SSH válida"
ErrSSHPrivateKey: "Chave privada SSH inválida ou senha incorreta; chaves criptografadas exigem a senha original"
ErrSSHKeyMismatch: "As chaves pública e privada SSH não correspondem"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Имя целевого файла: {{ .name }}'
ErrFirewallBackendCleanupRequired: "В текущем бэкенде {{ .current }} остались правила 1Panel. Удалите их перед переключением на {{ .target }}."
ErrDockerIPv4ForwardingDisabled: "Пересылка IPv4 отключена. Перед использованием бэкенда межсетевого экрана Docker установите net.ipv4.ip_forward=1."
ErrFirewallRuleSavedApplyFailed: "Настройки нового правила сохранены, но применить их к межсетевому экрану не удалось. Повторите попытку с помощью синхронизации: {{ .detail }}"
ErrSSHPublicKey: "Введите действительный открытый ключ SSH"
ErrSSHPrivateKey: "Недопустимый закрытый ключ SSH или неверная парольная фраза; для зашифрованных ключей требуется исходная парольная фраза"
ErrSSHKeyMismatch: "Открытый и закрытый ключи SSH не соответствуют друг другу"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: 'Hedef dosya adı: {{ .name }}'
ErrFirewallBackendCleanupRequired: "Mevcut {{ .current }} arka ucunda hâlâ 1Panel kuralları var. {{ .target }} arka ucuna geçmeden önce bunları temizleyin."
ErrDockerIPv4ForwardingDisabled: "IPv4 yönlendirmesi devre dışı. Docker güvenlik duvarı arka ucunu kullanmadan önce net.ipv4.ip_forward=1 ayarını yapın."
ErrFirewallRuleSavedApplyFailed: "Yeni kural yapılandırması kaydedildi, ancak güvenlik duvarına uygulanamadı. Eşitleme yaparak yeniden deneyin: {{ .detail }}"
ErrSSHPublicKey: "Geçerli bir SSH açık anahtarı girin"
ErrSSHPrivateKey: "SSH özel anahtarı geçersiz veya parola yanlış; şifreli anahtarlar mevcut parolayı gerektirir"
ErrSSHKeyMismatch: "SSH açık ve özel anahtarları eşleşmiyor"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: '目標檔名:{{ .name }}'
ErrFirewallBackendCleanupRequired: "目前後端 {{ .current }} 中仍有 1Panel 規則,請先清理後再切換至 {{ .target }}。"
ErrDockerIPv4ForwardingDisabled: "IPv4 轉送尚未啟用,請先設定 net.ipv4.ip_forward=1,再使用 Docker 防火牆後端。"
ErrFirewallRuleSavedApplyFailed: "新規則設定已儲存,但套用至防火牆失敗。可透過同步重試:{{ .detail }}"
ErrSSHPublicKey: "請輸入有效的 SSH 公鑰"
ErrSSHPrivateKey: "SSH 私鑰格式無效或密碼錯誤;加密私鑰需要填寫原密碼"
ErrSSHKeyMismatch: "SSH 公鑰與私鑰不相符"
+4
View File
@@ -731,3 +731,7 @@ FileTaskRename: '目标文件名:{{ .name }}'
ErrFirewallBackendCleanupRequired: "当前后端 {{ .current }} 中仍有 1Panel 规则,请先清理后再切换到 {{ .target }}。"
ErrDockerIPv4ForwardingDisabled: "IPv4 转发未开启,请先设置 net.ipv4.ip_forward=1,再使用 Docker 防火墙后端。"
ErrFirewallRuleSavedApplyFailed: "新规则配置已保存,但应用到防火墙失败。可通过同步重试:{{ .detail }}"
ErrSSHPublicKey: "请输入有效的 SSH 公钥"
ErrSSHPrivateKey: "SSH 私钥格式无效或密码错误;加密私钥需要填写原密码"
ErrSSHKeyMismatch: "SSH 公钥与私钥不匹配"
+3
View File
@@ -2888,6 +2888,9 @@ const message = {
privateKey: 'Private Key',
publicKey: 'Public Key',
password: 'Password',
existingPassPhrase: 'Existing private key passphrase',
existingPassPhraseHelper:
'Enter the original passphrase if the private key is encrypted; otherwise leave blank. This does not change the passphrase.',
createMode: 'Creation Method',
generate: 'Auto-generate',
unSyncPass: 'Key password cannot be synchronized',
+3
View File
@@ -2930,6 +2930,9 @@ const message = {
privateKey: 'Clave privada',
publicKey: 'Clave pública',
password: 'Contraseña',
existingPassPhrase: 'Contraseña actual de la clave privada',
existingPassPhraseHelper:
'Introduce la contraseña original si la clave privada está cifrada; en caso contrario, deja el campo vacío. Esto no cambia la contraseña.',
createMode: 'Método de creación',
generate: 'Autogenerar',
unSyncPass: 'La contraseña de la clave no se puede sincronizar',
+3
View File
@@ -2861,6 +2861,9 @@ const message = {
privateKey: 'کلید خصوصی',
publicKey: 'کلید عمومی',
password: 'رمز عبور',
existingPassPhrase: 'عبارت عبور فعلی کلید خصوصی',
existingPassPhraseHelper:
'اگر کلید خصوصی رمزگذاری شده است، عبارت عبور اصلی را وارد کنید؛ در غیر این صورت خالی بگذارید. این کار عبارت عبور را تغییر نمی‌دهد.',
createMode: 'روش ایجاد',
generate: 'تولید خودکار',
unSyncPass: 'رمز عبور کلید قابل همگام‌سازی نیست',
+3
View File
@@ -2865,6 +2865,9 @@ const message = {
privateKey: '秘密鍵',
publicKey: '公開鍵',
password: 'パスワード',
existingPassPhrase: '既存の秘密鍵のパスフレーズ',
existingPassPhraseHelper:
'秘密鍵が暗号化されている場合は元のパスフレーズを入力し、それ以外は空欄にしてください。パスフレーズは変更されません。',
createMode: '作成方法',
generate: '自動生成',
unSyncPass: '鍵パスワードは同期できません',
+3
View File
@@ -2827,6 +2827,9 @@ const message = {
privateKey: '개인 키',
publicKey: '공개 키',
password: '비밀번호',
existingPassPhrase: '기존 개인 키 암호',
existingPassPhraseHelper:
'개인 키가 암호화되어 있으면 원래 암호를 입력하고, 그렇지 않으면 비워 두세요. 이 작업은 암호를 변경하지 않습니다.',
createMode: '생성 방식',
generate: '자동 생성',
unSyncPass: '키 비밀번호 동기화 불가',
+3
View File
@@ -2807,6 +2807,9 @@ const message = {
privateKey: 'ຄີສ່ວນຕົວ',
publicKey: 'ຄີສາທາລະນະ',
password: 'ລະຫັດຜ່ານ',
existingPassPhrase: 'ລະຫັດຜ່ານເດີມຂອງຄີສ່ວນຕົວ',
existingPassPhraseHelper:
'ຖ້າຄີສ່ວນຕົວຖືກເຂົ້າລະຫັດ ໃຫ້ປ້ອນລະຫັດຜ່ານເດີມ; ຖ້າບໍ່ ໃຫ້ປະໄວ້ຫວ່າງ. ການດຳເນີນການນີ້ບໍ່ປ່ຽນລະຫັດຜ່ານ.',
createMode: 'ວິທີການສ້າງ',
generate: 'ສ້າງອັດຕະໂນມັດ',
unSyncPass: 'ບໍ່ສາມາດຊິງຄ໌ລະຫັດຜ່ານຄີໄດ້',
+3
View File
@@ -2930,6 +2930,9 @@ const message = {
privateKey: 'Kunci Persendirian',
publicKey: 'Kunci Awam',
password: 'Kata Laluan',
existingPassPhrase: 'Frasa laluan kunci peribadi sedia ada',
existingPassPhraseHelper:
'Masukkan frasa laluan asal jika kunci peribadi disulitkan; jika tidak, biarkan kosong. Ini tidak mengubah frasa laluan.',
createMode: 'Kaedah Penciptaan',
generate: 'Jana Automatik',
unSyncPass: 'Kata laluan kunci tidak dapat diselaraskan',
+3
View File
@@ -2929,6 +2929,9 @@ const message = {
privateKey: 'Chave Privada',
publicKey: 'Chave Pública',
password: 'Senha',
existingPassPhrase: 'Senha atual da chave privada',
existingPassPhraseHelper:
'Informe a senha original se a chave privada estiver criptografada; caso contrário, deixe em branco. Isso não altera a senha.',
createMode: 'Método de Criação',
generate: 'Gerar Automaticamente',
unSyncPass: 'Senha da chave não pode ser sincronizada',
+3
View File
@@ -2903,6 +2903,9 @@ const message = {
privateKey: 'Приватный ключ',
publicKey: 'Публичный ключ',
password: 'Пароль',
existingPassPhrase: 'Текущая парольная фраза закрытого ключа',
existingPassPhraseHelper:
'Если закрытый ключ зашифрован, введите исходную парольную фразу; иначе оставьте поле пустым. Это не изменяет парольную фразу.',
createMode: 'Способ создания',
generate: 'Автогенерация',
unSyncPass: 'Пароль ключа не может быть синхронизирован',
+3
View File
@@ -2921,6 +2921,9 @@ const message = {
privateKey: 'Özel Anahtar',
publicKey: 'Genel Anahtar',
password: 'Parola',
existingPassPhrase: 'Mevcut özel anahtar parolası',
existingPassPhraseHelper:
'Özel anahtar şifreliyse mevcut parolayı girin; değilse boş bırakın. Bu işlem parolayı değiştirmez.',
createMode: 'Oluşturma Yöntemi',
generate: 'Otomatik Oluştur',
unSyncPass: 'Anahtar parolası senkronize edilemez',
+2
View File
@@ -2720,6 +2720,8 @@ const message = {
privateKey: '私鑰',
publicKey: '公鑰',
password: '密碼',
existingPassPhrase: '現有私鑰密碼',
existingPassPhraseHelper: '私鑰已加密時填寫原密碼,未加密則留空;此處不會修改私鑰密碼。',
createMode: '建立方式',
generate: '自動生成',
unSyncPass: '金鑰密碼無法同步',
+2
View File
@@ -2751,6 +2751,8 @@ const message = {
privateKey: '私钥',
publicKey: '公钥',
password: '密码',
existingPassPhrase: '已有私钥密码',
existingPassPhraseHelper: '私钥已加密时填写原密码,未加密则留空;此处不会修改私钥密码。',
createMode: '创建方式',
generate: '自动生成',
unSyncPass: '密钥密码无法同步',
@@ -16,8 +16,15 @@
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('ssh.createMode')" prop="mode" v-if="dialogData.title === 'create'">
<el-radio-group v-model="dialogData.rowData.mode" @change="onModeChange">
<el-radio value="generate">{{ $t('ssh.generate') }}</el-radio>
<el-radio value="input">{{ $t('ssh.input') }}</el-radio>
<el-radio value="import">{{ $t('ssh.import') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-col :span="12" v-if="isGenerate">
<el-form-item :label="$t('ssh.encryptionMode')" prop="encryptionMode">
<el-select v-model="dialogData.rowData.encryptionMode">
<el-option label="ED25519" value="ed25519" />
@@ -28,50 +35,38 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('commons.login.password')" prop="passPhrase">
<el-form-item :label="$t(isGenerate ? 'ssh.password' : 'ssh.existingPassPhrase')" prop="passPhrase">
<el-input v-model="dialogData.rowData.passPhrase" type="password" show-password>
<template #append>
<template #append v-if="isGenerate">
<el-button @click="random">
{{ $t('commons.button.random') }}
</el-button>
</template>
</el-input>
<span class="input-help" v-if="!isGenerate">{{ $t('ssh.existingPassPhraseHelper') }}</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('ssh.createMode')" prop="mode" v-if="dialogData.title === 'create'">
<el-radio-group v-model="dialogData.rowData.mode">
<el-radio value="generate">{{ $t('ssh.generate') }}</el-radio>
<el-radio value="input">{{ $t('ssh.input') }}</el-radio>
<el-radio value="import">{{ $t('ssh.import') }}</el-radio>
</el-radio-group>
</el-form-item>
<div v-if="dialogData.rowData.mode === 'input'">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('ssh.privateKey')" prop="privateKey">
<el-input type="textarea" :rows="2" v-model="dialogData.rowData.privateKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('ssh.publicKey')" prop="publicKey">
<el-input type="textarea" :rows="2" v-model="dialogData.rowData.publicKey" />
</el-form-item>
</el-col>
</el-row>
</div>
<div v-if="dialogData.rowData.mode === 'import'">
<div v-if="!isGenerate">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('ssh.privateKey')" prop="privateKey">
<el-input
v-if="dialogData.rowData.mode === 'input'"
type="textarea"
:rows="4"
v-model="dialogData.rowData.privateKey"
/>
<el-upload
v-else
action="#"
:auto-upload="false"
ref="uploadPrivateRef"
class="upload mt-2 w-full"
:limit="1"
:on-change="privateOnChange"
:on-change="(file) => onKeyChange('privateKey', file)"
:on-exceed="privateExceed"
:on-remove="() => onKeyRemove('privateKey')"
>
<el-button size="small" icon="Upload">
{{ $t('commons.button.upload') }}
@@ -81,14 +76,22 @@
</el-col>
<el-col :span="12">
<el-form-item :label="$t('ssh.publicKey')" prop="publicKey">
<el-input
v-if="dialogData.rowData.mode === 'input'"
type="textarea"
:rows="4"
v-model="dialogData.rowData.publicKey"
/>
<el-upload
v-else
action="#"
:auto-upload="false"
ref="uploadPublicRef"
class="upload mt-2 w-full"
:limit="1"
:on-change="publicOnChange"
:on-change="(file) => onKeyChange('publicKey', file)"
:on-exceed="publicExceed"
:on-remove="() => onKeyRemove('publicKey')"
>
<el-button size="small" icon="Upload">
{{ $t('commons.button.upload') }}
@@ -104,7 +107,7 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="onConfirm(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
@@ -114,7 +117,7 @@
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import i18n from '@/lang';
import { ElForm, genFileId, UploadFile, UploadProps, UploadRawFile } from 'element-plus';
import { Host } from '@/api/interface/host';
@@ -139,37 +142,91 @@ type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref();
const uploadPrivateRef = ref();
const uploadPublicRef = ref();
const isGenerate = computed(() => dialogData.value.title === 'create' && dialogData.value.rowData?.mode === 'generate');
type KeyField = 'privateKey' | 'publicKey';
const keyReaders: Partial<Record<KeyField, FileReader>> = {};
const cancelKeyRead = (field?: KeyField) => {
const fields: KeyField[] = field ? [field] : ['privateKey', 'publicKey'];
for (const key of fields) {
const reader = keyReaders[key];
delete keyReaders[key];
reader?.abort();
}
};
onBeforeUnmount(() => cancelKeyRead());
const acceptParams = (params: DialogProps): void => {
cancelKeyRead();
dialogData.value = params;
params.rowData.passPhrase ||= '';
params.rowData.privateKey ||= '';
params.rowData.publicKey ||= '';
if (params.title === 'edit') {
params.rowData.mode = 'input';
dialogData.value.rowData.publicKey = Base64.decode(params.rowData.publicKey);
dialogData.value.rowData.privateKey = Base64.decode(params.rowData.privateKey);
if (params.rowData.passPhrase) {
dialogData.value.rowData.passPhrase = Base64.decode(params.rowData.passPhrase);
if (dialogData.value.rowData.passPhrase === '<UN-SET>') {
dialogData.value.rowData.passPhrase = '';
}
}
}
if (!isGenerate.value) {
dialogData.value.rowData.encryptionMode = '';
}
title.value = i18n.global.t('commons.button.' + dialogData.value.title);
drawerVisible.value = true;
};
const emit = defineEmits<{ (e: 'search'): void }>();
function checkPassword(rule: any, value: any, callback: any) {
if (dialogData.value.rowData.passPhrase !== '') {
if (isGenerate.value && value) {
const reg = /^[A-Za-z0-9]{6,15}$/;
if (!reg.test(dialogData.value.rowData.passPhrase)) {
if (!reg.test(value)) {
return callback(new Error(i18n.global.t('ssh.passwordHelper')));
}
}
callback();
}
const checkKey = (rule: { field: string }, value: string, callback: (error?: Error) => void) => {
if (isGenerate.value) {
return callback();
}
const content = value?.trim();
if (!content) {
return callback(new Error(i18n.global.t('commons.rule.requiredInput')));
}
const pattern =
rule.field === 'publicKey'
? /^(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))[ \t]+[A-Za-z0-9+/]+={0,2}(?:[ \t]+[^\r\n]*)?$/
: /^-----BEGIN ((?:OPENSSH |RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY)-----\r?\n(?:Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: [^\r\n]+\r?\n\r?\n)?[A-Za-z0-9+/][A-Za-z0-9+/=\r\n]*\r?\n-----END \1-----$/;
if (!pattern.test(content)) {
return callback(new Error(i18n.global.t('commons.rule.formatErr')));
}
callback();
};
const onModeChange = () => {
cancelKeyRead();
dialogData.value.rowData.encryptionMode = isGenerate.value ? 'ed25519' : '';
dialogData.value.rowData.passPhrase = '';
dialogData.value.rowData.privateKey = '';
dialogData.value.rowData.publicKey = '';
uploadPrivateRef.value?.clearFiles();
uploadPublicRef.value?.clearFiles();
formRef.value?.clearValidate();
};
const rules = reactive({
name: Rules.simpleName,
encryptionMode: Rules.requiredSelect,
passPhrase: [{ validator: checkPassword, trigger: 'blur' }],
privateKey: [Rules.requiredInput],
publicKey: [Rules.requiredInput],
privateKey: [{ required: true, validator: checkKey, trigger: ['blur', 'change'] }],
publicKey: [{ required: true, validator: checkKey, trigger: ['blur', 'change'] }],
});
const onConfirm = async (formEl: FormInstance | undefined) => {
@@ -177,23 +234,27 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
const request = {
...dialogData.value.rowData,
encryptionMode: isGenerate.value ? dialogData.value.rowData.encryptionMode : '',
};
if (dialogData.value.title === 'create') {
await createCert(dialogData.value.rowData)
await createCert(request)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisible.value = false;
handleClose();
emit('search');
})
.catch(() => {
loading.value = false;
});
} else {
await editCert(dialogData.value.rowData)
await editCert(request)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisible.value = false;
handleClose();
emit('search');
})
.catch(() => {
@@ -203,35 +264,41 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
});
};
const privateOnChange = (_uploadFile: UploadFile) => {
const onKeyRemove = (field: KeyField) => {
cancelKeyRead(field);
dialogData.value.rowData[field] = '';
formRef.value?.validateField(field).catch(() => {});
};
const onKeyChange = (field: KeyField, uploadFile: UploadFile) => {
const rowData = dialogData.value.rowData;
if (!drawerVisible.value || rowData.mode !== 'import') return;
cancelKeyRead(field);
rowData[field] = '';
const reader = new FileReader();
keyReaders[field] = reader;
reader.onload = (e) => {
try {
dialogData.value.rowData.privateKey = e.target.result as string;
} catch (error) {
MsgError(i18n.global.t('commons.msg.errImport') + error.message);
}
if (keyReaders[field] !== reader || dialogData.value.rowData !== rowData) return;
delete keyReaders[field];
rowData[field] = e.target.result as string;
formRef.value?.validateField(field).catch(() => {});
};
reader.readAsText(_uploadFile.raw);
reader.onerror = () => {
if (keyReaders[field] !== reader || dialogData.value.rowData !== rowData) return;
delete keyReaders[field];
MsgError(i18n.global.t('commons.msg.errImport'));
};
reader.readAsText(uploadFile.raw);
};
const privateExceed: UploadProps['onExceed'] = (files) => {
onKeyRemove('privateKey');
uploadPrivateRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadPrivateRef.value!.handleStart(file);
};
const publicOnChange = (_uploadFile: UploadFile) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
dialogData.value.rowData.publicKey = e.target.result as string;
} catch (error) {
MsgError(i18n.global.t('commons.msg.errImport') + error.message);
}
};
reader.readAsText(_uploadFile.raw);
};
const publicExceed: UploadProps['onExceed'] = (files) => {
onKeyRemove('publicKey');
uploadPublicRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
@@ -243,6 +310,7 @@ const random = async () => {
};
const handleClose = () => {
cancelKeyRead();
drawerVisible.value = false;
};