diff --git a/internal/web/actions/default/servers/certs/init.go b/internal/web/actions/default/servers/certs/init.go
index fe8c6c7b..89739b55 100644
--- a/internal/web/actions/default/servers/certs/init.go
+++ b/internal/web/actions/default/servers/certs/init.go
@@ -21,6 +21,7 @@ func init() {
Data("leftMenuItem", "cert").
Get("", new(IndexAction)).
GetPost("/uploadPopup", new(UploadPopupAction)).
+ GetPost("/uploadBatchPopup", new(UploadBatchPopupAction)).
Post("/delete", new(DeleteAction)).
GetPost("/updatePopup", new(UpdatePopupAction)).
Get("/certPopup", new(CertPopupAction)).
diff --git a/internal/web/actions/default/servers/certs/uploadBatchPopup.go b/internal/web/actions/default/servers/certs/uploadBatchPopup.go
new file mode 100644
index 00000000..b0c4b1a0
--- /dev/null
+++ b/internal/web/actions/default/servers/certs/uploadBatchPopup.go
@@ -0,0 +1,171 @@
+// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
+
+package certs
+
+import (
+ "bytes"
+ "crypto/tls"
+ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
+ "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+ "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs"
+ "github.com/iwind/TeaGo/actions"
+ "github.com/iwind/TeaGo/types"
+ "io"
+ "mime/multipart"
+ "strings"
+)
+
+type UploadBatchPopupAction struct {
+ actionutils.ParentAction
+}
+
+func (this *UploadBatchPopupAction) Init() {
+ this.Nav("", "", "")
+}
+
+func (this *UploadBatchPopupAction) RunGet(params struct{}) {
+ this.Show()
+}
+
+func (this *UploadBatchPopupAction) RunPost(params struct {
+ UserId int64
+
+ Must *actions.Must
+ CSRF *actionutils.CSRF
+}) {
+ defer this.CreateLogInfo("批量上传证书")
+
+ var files = this.Request.MultipartForm.File["certFiles"]
+ if len(files) == 0 {
+ this.Fail("请选择要上传的证书和私钥文件")
+ return
+ }
+
+ // 限制每次上传的文件数量
+ const maxFiles = 10_000
+ if len(files) > maxFiles {
+ this.Fail("每次上传最多不能超过" + types.String(maxFiles) + "个文件")
+ return
+ }
+
+ type certInfo struct {
+ filename string
+ data []byte
+ }
+
+ var certDataList = []*certInfo{}
+ var keyDataList = [][]byte{}
+
+ var failMessages = []string{}
+ for _, file := range files {
+ func(file *multipart.FileHeader) {
+ fp, err := file.Open()
+ if err != nil {
+ failMessages = append(failMessages, "文件"+file.Filename+"读取失败:"+err.Error())
+ return
+ }
+
+ defer func() {
+ _ = fp.Close()
+ }()
+
+ data, err := io.ReadAll(fp)
+ if err != nil {
+ failMessages = append(failMessages, "文件"+file.Filename+"读取失败:"+err.Error())
+ return
+ }
+
+ if bytes.Contains(data, []byte("CERTIFICATE-")) {
+ certDataList = append(certDataList, &certInfo{
+ filename: file.Filename,
+ data: data,
+ })
+ } else if bytes.Contains(data, []byte("PRIVATE KEY-")) {
+ keyDataList = append(keyDataList, data)
+ } else {
+ failMessages = append(failMessages, "文件"+file.Filename+"读取失败:文件格式错误,无法识别是证书还是私钥")
+ return
+ }
+ }(file)
+ }
+
+ if len(failMessages) > 0 {
+ this.Fail("发生了错误:" + strings.Join(failMessages, ";"))
+ return
+ }
+
+ // 自动匹配
+ var pairs = [][2][]byte{} // [] { cert, key }
+ var keyIndexMap = map[int]bool{} // 方便下面跳过已匹配的Key
+ for _, cert := range certDataList {
+ var found = false
+ for keyIndex, keyData := range keyDataList {
+ if keyIndexMap[keyIndex] {
+ continue
+ }
+
+ _, err := tls.X509KeyPair(cert.data, keyData)
+ if err == nil {
+ found = true
+ pairs = append(pairs, [2][]byte{cert.data, keyData})
+ keyIndexMap[keyIndex] = true
+ break
+ }
+ }
+ if !found {
+ this.Fail("找不到" + cert.filename + "对应的私钥")
+ return
+ }
+ }
+
+ // 组织 CertConfig
+ var pbCerts = []*pb.CreateSSLCertsRequestCert{}
+ for _, pair := range pairs {
+ certData, keyData := pair[0], pair[1]
+
+ var certConfig = &sslconfigs.SSLCertConfig{
+ IsCA: false,
+ CertData: certData,
+ KeyData: keyData,
+ }
+ err := certConfig.Init(nil)
+ if err != nil {
+ this.Fail("证书验证失败:" + err.Error())
+ return
+ }
+
+ var certName = ""
+ if len(certConfig.DNSNames) > 0 {
+ certName = certConfig.DNSNames[0]
+ if len(certConfig.DNSNames) > 1 {
+ certName += "等" + types.String(len(certConfig.DNSNames)) + "个域名"
+ }
+ }
+
+ pbCerts = append(pbCerts, &pb.CreateSSLCertsRequestCert{
+ IsOn: true,
+ Name: certName,
+ Description: "",
+ ServerName: "",
+ IsCA: false,
+ CertData: certData,
+ KeyData: keyData,
+ TimeBeginAt: certConfig.TimeBeginAt,
+ TimeEndAt: certConfig.TimeEndAt,
+ DnsNames: certConfig.DNSNames,
+ CommonNames: certConfig.CommonNames,
+ })
+ }
+
+ _, err := this.RPC().SSLCertRPC().CreateSSLCerts(this.AdminContext(), &pb.CreateSSLCertsRequest{
+ UserId: params.UserId,
+ SSLCerts: pbCerts,
+ })
+ if err != nil {
+ this.ErrorPage(err)
+ return
+ }
+
+ this.Data["count"] = len(pbCerts)
+ this.Success()
+}
diff --git a/web/views/@default/servers/certs/index.html b/web/views/@default/servers/certs/index.html
index b9d1d2c8..0d1ce6a5 100644
--- a/web/views/@default/servers/certs/index.html
+++ b/web/views/@default/servers/certs/index.html
@@ -11,6 +11,7 @@