mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-12 08:00:34 +00:00
fix(ssh): validate imported key pairs and cancel stale file reads (#13991)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 مطابقت ندارند"
|
||||
|
||||
@@ -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 公開鍵と秘密鍵が一致しません"
|
||||
|
||||
@@ -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 공개 키와 개인 키가 일치하지 않습니다"
|
||||
|
||||
@@ -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 ບໍ່ກົງກັນ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 не соответствуют друг другу"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 公鑰與私鑰不相符"
|
||||
|
||||
@@ -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 公钥与私钥不匹配"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2861,6 +2861,9 @@ const message = {
|
||||
privateKey: 'کلید خصوصی',
|
||||
publicKey: 'کلید عمومی',
|
||||
password: 'رمز عبور',
|
||||
existingPassPhrase: 'عبارت عبور فعلی کلید خصوصی',
|
||||
existingPassPhraseHelper:
|
||||
'اگر کلید خصوصی رمزگذاری شده است، عبارت عبور اصلی را وارد کنید؛ در غیر این صورت خالی بگذارید. این کار عبارت عبور را تغییر نمیدهد.',
|
||||
createMode: 'روش ایجاد',
|
||||
generate: 'تولید خودکار',
|
||||
unSyncPass: 'رمز عبور کلید قابل همگامسازی نیست',
|
||||
|
||||
@@ -2865,6 +2865,9 @@ const message = {
|
||||
privateKey: '秘密鍵',
|
||||
publicKey: '公開鍵',
|
||||
password: 'パスワード',
|
||||
existingPassPhrase: '既存の秘密鍵のパスフレーズ',
|
||||
existingPassPhraseHelper:
|
||||
'秘密鍵が暗号化されている場合は元のパスフレーズを入力し、それ以外は空欄にしてください。パスフレーズは変更されません。',
|
||||
createMode: '作成方法',
|
||||
generate: '自動生成',
|
||||
unSyncPass: '鍵パスワードは同期できません',
|
||||
|
||||
@@ -2827,6 +2827,9 @@ const message = {
|
||||
privateKey: '개인 키',
|
||||
publicKey: '공개 키',
|
||||
password: '비밀번호',
|
||||
existingPassPhrase: '기존 개인 키 암호',
|
||||
existingPassPhraseHelper:
|
||||
'개인 키가 암호화되어 있으면 원래 암호를 입력하고, 그렇지 않으면 비워 두세요. 이 작업은 암호를 변경하지 않습니다.',
|
||||
createMode: '생성 방식',
|
||||
generate: '자동 생성',
|
||||
unSyncPass: '키 비밀번호 동기화 불가',
|
||||
|
||||
@@ -2807,6 +2807,9 @@ const message = {
|
||||
privateKey: 'ຄີສ່ວນຕົວ',
|
||||
publicKey: 'ຄີສາທາລະນະ',
|
||||
password: 'ລະຫັດຜ່ານ',
|
||||
existingPassPhrase: 'ລະຫັດຜ່ານເດີມຂອງຄີສ່ວນຕົວ',
|
||||
existingPassPhraseHelper:
|
||||
'ຖ້າຄີສ່ວນຕົວຖືກເຂົ້າລະຫັດ ໃຫ້ປ້ອນລະຫັດຜ່ານເດີມ; ຖ້າບໍ່ ໃຫ້ປະໄວ້ຫວ່າງ. ການດຳເນີນການນີ້ບໍ່ປ່ຽນລະຫັດຜ່ານ.',
|
||||
createMode: 'ວິທີການສ້າງ',
|
||||
generate: 'ສ້າງອັດຕະໂນມັດ',
|
||||
unSyncPass: 'ບໍ່ສາມາດຊິງຄ໌ລະຫັດຜ່ານຄີໄດ້',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2903,6 +2903,9 @@ const message = {
|
||||
privateKey: 'Приватный ключ',
|
||||
publicKey: 'Публичный ключ',
|
||||
password: 'Пароль',
|
||||
existingPassPhrase: 'Текущая парольная фраза закрытого ключа',
|
||||
existingPassPhraseHelper:
|
||||
'Если закрытый ключ зашифрован, введите исходную парольную фразу; иначе оставьте поле пустым. Это не изменяет парольную фразу.',
|
||||
createMode: 'Способ создания',
|
||||
generate: 'Автогенерация',
|
||||
unSyncPass: 'Пароль ключа не может быть синхронизирован',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2720,6 +2720,8 @@ const message = {
|
||||
privateKey: '私鑰',
|
||||
publicKey: '公鑰',
|
||||
password: '密碼',
|
||||
existingPassPhrase: '現有私鑰密碼',
|
||||
existingPassPhraseHelper: '私鑰已加密時填寫原密碼,未加密則留空;此處不會修改私鑰密碼。',
|
||||
createMode: '建立方式',
|
||||
generate: '自動生成',
|
||||
unSyncPass: '金鑰密碼無法同步',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user