diff --git a/internal/biz/cert.go b/internal/biz/cert.go index a412cbd011..b58092638d 100644 --- a/internal/biz/cert.go +++ b/internal/biz/cert.go @@ -1,6 +1,11 @@ package biz -import "github.com/golang-module/carbon/v2" +import ( + "github.com/golang-module/carbon/v2" + + "github.com/TheTNB/panel/internal/http/request" + "github.com/TheTNB/panel/pkg/acme" +) type Cert struct { ID uint `gorm:"primaryKey" json:"id"` @@ -20,3 +25,16 @@ type Cert struct { User *CertUser `gorm:"foreignKey:UserID" json:"user"` DNS *CertDNS `gorm:"foreignKey:DNSID" json:"dns"` } + +type CertRepo interface { + List(page, limit uint) ([]*Cert, int64, error) + Get(id uint) (*Cert, error) + Create(req *request.CertCreate) (*Cert, error) + Update(req *request.CertUpdate) error + Delete(id uint) error + ObtainAuto(id uint) (*acme.Certificate, error) + ObtainManual(id uint) (*acme.Certificate, error) + Renew(id uint) (*acme.Certificate, error) + ManualDNS(id uint) ([]acme.DNSRecord, error) + Deploy(ID, WebsiteID uint) error +} diff --git a/internal/biz/task.go b/internal/biz/task.go index 9cea91e804..0dff9ce5c1 100644 --- a/internal/biz/task.go +++ b/internal/biz/task.go @@ -13,7 +13,7 @@ type Task struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"not null;index" json:"name"` Status string `gorm:"not null;default:'waiting'" json:"status"` - Shell string `gorm:"not null" json:"shell"` + Shell string `gorm:"not null" json:"-"` Log string `gorm:"not null" json:"log"` CreatedAt carbon.DateTime `json:"created_at"` UpdatedAt carbon.DateTime `json:"updated_at"` @@ -21,4 +21,7 @@ type Task struct { type TaskRepo interface { HasRunningTask() bool + List(page, limit uint) ([]*Task, int64, error) + Get(id uint) (*Task, error) + Delete(id uint) error } diff --git a/internal/data/cert.go b/internal/data/cert.go new file mode 100644 index 0000000000..f432d87549 --- /dev/null +++ b/internal/data/cert.go @@ -0,0 +1,273 @@ +package data + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/TheTNB/panel/internal/app" + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/http/request" + "github.com/TheTNB/panel/pkg/acme" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/systemctl" +) + +type certRepo struct { + client *acme.Client + websiteRepo biz.WebsiteRepo +} + +func NewCertRepo() biz.CertRepo { + return &certRepo{ + websiteRepo: NewWebsiteRepo(), + } +} + +func (r *certRepo) List(page, limit uint) ([]*biz.Cert, int64, error) { + var certs []*biz.Cert + var total int64 + err := app.Orm.Model(&biz.Cert{}).Order("id desc").Count(&total).Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&certs).Error + return certs, total, err +} + +func (r *certRepo) Get(id uint) (*biz.Cert, error) { + cert := new(biz.Cert) + err := app.Orm.Model(&biz.Cert{}).Where("id = ?", id).First(cert).Error + return cert, err +} + +func (r *certRepo) Create(req *request.CertCreate) (*biz.Cert, error) { + cert := &biz.Cert{ + UserID: req.UserID, + WebsiteID: req.WebsiteID, + DNSID: req.DNSID, + Type: req.Type, + Domains: req.Domains, + AutoRenew: req.AutoRenew, + } + if err := app.Orm.Create(cert).Error; err != nil { + return nil, err + } + return cert, nil +} + +func (r *certRepo) Update(req *request.CertUpdate) error { + return app.Orm.Model(&biz.Cert{}).Where("id = ?", req.ID).Updates(&biz.Cert{ + UserID: req.UserID, + WebsiteID: req.WebsiteID, + DNSID: req.DNSID, + Type: req.Type, + Domains: req.Domains, + AutoRenew: req.AutoRenew, + }).Error +} + +func (r *certRepo) Delete(id uint) error { + return app.Orm.Model(&biz.Cert{}).Where("id = ?", id).Delete(&biz.Cert{}).Error +} + +func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) { + cert, err := r.Get(id) + if err != nil { + return nil, err + } + + client, err := r.getClient(cert) + if err != nil { + return nil, err + } + + if cert.DNS != nil { + client.UseDns(acme.DnsType(cert.DNS.Type), cert.DNS.Data) + } else { + if cert.Website == nil { + return nil, errors.New("该证书没有关联网站,无法自动签发") + } else { + for _, domain := range cert.Domains { + if strings.Contains(domain, "*") { + return nil, errors.New("通配符域名无法使用 HTTP 验证") + } + } + conf := fmt.Sprintf("%s/server/vhost/acme/%s.conf", app.Root, cert.Website.Name) + client.UseHTTP(conf, cert.Website.Path) + } + } + + ssl, err := client.ObtainSSL(context.Background(), cert.Domains, acme.KeyType(cert.Type)) + if err != nil { + return nil, err + } + + cert.CertURL = ssl.URL + cert.Cert = string(ssl.ChainPEM) + cert.Key = string(ssl.PrivateKey) + if err = app.Orm.Save(cert).Error; err != nil { + return nil, err + } + + if cert.Website != nil { + return &ssl, r.Deploy(cert.ID, cert.WebsiteID) + } + + return &ssl, nil +} + +func (r *certRepo) ObtainManual(id uint) (*acme.Certificate, error) { + cert, err := r.Get(id) + if err != nil { + return nil, err + } + + if r.client == nil { + return nil, errors.New("请重新获取 DNS 解析记录") + } + + ssl, err := r.client.ObtainSSLManual() + if err != nil { + return nil, err + } + + cert.CertURL = ssl.URL + cert.Cert = string(ssl.ChainPEM) + cert.Key = string(ssl.PrivateKey) + if err = app.Orm.Save(cert).Error; err != nil { + return nil, err + } + + if cert.Website != nil { + return &ssl, r.Deploy(cert.ID, cert.WebsiteID) + } + + return &ssl, nil +} + +func (r *certRepo) Renew(id uint) (*acme.Certificate, error) { + cert, err := r.Get(id) + if err != nil { + return nil, err + } + + client, err := r.getClient(cert) + if err != nil { + return nil, err + } + + if cert.CertURL == "" { + return nil, errors.New("该证书没有签发成功,无法续签") + } + + if cert.DNS != nil { + client.UseDns(acme.DnsType(cert.DNS.Type), cert.DNS.Data) + } else { + if cert.Website == nil { + return nil, errors.New("该证书没有关联网站,无法续签,可以尝试手动签发") + } else { + for _, domain := range cert.Domains { + if strings.Contains(domain, "*") { + return nil, errors.New("通配符域名无法使用 HTTP 验证") + } + } + conf := fmt.Sprintf("/www/server/vhost/acme/%s.conf", cert.Website.Name) + client.UseHTTP(conf, cert.Website.Path) + } + } + + ssl, err := client.RenewSSL(context.Background(), cert.CertURL, cert.Domains, acme.KeyType(cert.Type)) + if err != nil { + return nil, err + } + + cert.CertURL = ssl.URL + cert.Cert = string(ssl.ChainPEM) + cert.Key = string(ssl.PrivateKey) + if err = app.Orm.Save(cert).Error; err != nil { + return nil, err + } + + if cert.Website != nil { + return &ssl, r.Deploy(cert.ID, cert.WebsiteID) + } + + return &ssl, nil +} + +func (r *certRepo) ManualDNS(id uint) ([]acme.DNSRecord, error) { + cert, err := r.Get(id) + if err != nil { + return nil, err + } + + client, err := r.getClient(cert) + if err != nil { + return nil, err + } + + client.UseManualDns(len(cert.Domains)) + records, err := client.GetDNSRecords(context.Background(), cert.Domains, acme.KeyType(cert.Type)) + if err != nil { + return nil, err + } + + // 15 分钟后清理客户端 + r.client = client + time.AfterFunc(15*time.Minute, func() { + r.client = nil + }) + + return records, nil +} + +func (r *certRepo) Deploy(ID, WebsiteID uint) error { + cert, err := r.Get(ID) + if err != nil { + return err + } + + if cert.Cert == "" || cert.Key == "" { + return errors.New("该证书没有签发成功,无法部署") + } + + website, err := r.websiteRepo.Get(WebsiteID) + if err != nil { + return err + } + + if err = io.Write(fmt.Sprintf("%s/server/vhost/ssl/%s.pem", app.Root, website.Name), cert.Cert, 0644); err != nil { + return err + } + if err = io.Write(fmt.Sprintf("%s/server/vhost/ssl/%s.key", app.Root, website.Name), cert.Key, 0644); err != nil { + return err + } + if err = systemctl.Reload("openresty"); err != nil { + _, err = shell.Execf("openresty -t") + return err + } + + return nil +} + +func (r *certRepo) getClient(cert *biz.Cert) (*acme.Client, error) { + var ca string + var eab *acme.EAB + switch cert.User.CA { + case "letsencrypt": + ca = acme.CALetsEncrypt + case "buypass": + ca = acme.CABuypass + case "zerossl": + ca = acme.CAZeroSSL + eab = &acme.EAB{KeyID: cert.User.Kid, MACKey: cert.User.HmacEncoded} + case "sslcom": + ca = acme.CASSLcom + eab = &acme.EAB{KeyID: cert.User.Kid, MACKey: cert.User.HmacEncoded} + case "google": + ca = acme.CAGoogle + eab = &acme.EAB{KeyID: cert.User.Kid, MACKey: cert.User.HmacEncoded} + } + + return acme.NewPrivateKeyAccount(cert.User.Email, cert.User.PrivateKey, ca, eab) +} diff --git a/internal/data/task.go b/internal/data/task.go index 7b421c25d6..12b3b2c623 100644 --- a/internal/data/task.go +++ b/internal/data/task.go @@ -16,3 +16,20 @@ func (r *taskRepo) HasRunningTask() bool { app.Orm.Model(&biz.Task{}).Where("status = ?", biz.TaskStatusRunning).Or("status = ?", biz.TaskStatusWaiting).Count(&count) return count > 0 } + +func (r *taskRepo) List(page, limit uint) ([]*biz.Task, int64, error) { + var tasks []*biz.Task + var total int64 + err := app.Orm.Model(&biz.Task{}).Order("id desc").Count(&total).Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&tasks).Error + return tasks, total, err +} + +func (r *taskRepo) Get(id uint) (*biz.Task, error) { + task := new(biz.Task) + err := app.Orm.Model(&biz.Task{}).Where("id = ?", id).First(task).Error + return task, err +} + +func (r *taskRepo) Delete(id uint) error { + return app.Orm.Model(&biz.Task{}).Where("id = ?", id).Delete(&biz.Task{}).Error +} diff --git a/internal/http/request/cert.go b/internal/http/request/cert.go new file mode 100644 index 0000000000..de3f74f03a --- /dev/null +++ b/internal/http/request/cert.go @@ -0,0 +1,25 @@ +package request + +type CertCreate struct { + Type string `form:"type" json:"type"` + Domains []string `form:"domains" json:"domains"` + AutoRenew bool `form:"auto_renew" json:"auto_renew"` + UserID uint `form:"user_id" json:"user_id"` + DNSID uint `form:"dns_id" json:"dns_id"` + WebsiteID uint `form:"website_id" json:"website_id"` +} + +type CertUpdate struct { + ID uint `form:"id" json:"id"` + Type string `form:"type" json:"type"` + Domains []string `form:"domains" json:"domains"` + AutoRenew bool `form:"auto_renew" json:"auto_renew"` + UserID uint `form:"user_id" json:"user_id"` + DNSID uint `form:"dns_id" json:"dns_id"` + WebsiteID uint `form:"website_id" json:"website_id"` +} + +type CertDeploy struct { + ID uint `form:"id" json:"id"` + WebsiteID uint `form:"website_id" json:"website_id"` +} diff --git a/internal/route/http.go b/internal/route/http.go index 474d35faca..8c0a994af7 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -40,8 +40,8 @@ func Http(r chi.Router) { r.Use(middleware.MustLogin) task := service.NewTaskService() r.Get("/status", task.Status) - r.Get("/", task.Index) - r.Get("/{id}", task.Show) // TODO 修改前端 + r.Get("/", task.List) + r.Get("/{id}", task.Get) // TODO 修改前端 r.Delete("/{id}", task.Delete) // TODO 修改前端 }) @@ -63,6 +63,7 @@ func Http(r chi.Router) { r.Post("/{id}/status", website.UpdateStatus) }) + // TODO r.Route("/backup", func(r chi.Router) { r.Use(middleware.MustLogin) backup := service.NewBackupService() diff --git a/internal/service/cert.go b/internal/service/cert.go index e1ccd5f7d6..9df4c330b6 100644 --- a/internal/service/cert.go +++ b/internal/service/cert.go @@ -1,58 +1,341 @@ package service -import "net/http" +import ( + "net/http" + + "github.com/go-rat/chix" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/data" + "github.com/TheTNB/panel/internal/http/request" + "github.com/TheTNB/panel/pkg/acme" +) type CertService struct { + certRepo biz.CertRepo } func NewCertService() *CertService { - return &CertService{} + return &CertService{ + certRepo: data.NewCertRepo(), + } } +// CAProviders +// +// @Summary 获取 CA 提供商 +// @Tags 证书服务 +// @Produce json +// @Success 200 {object} SuccessResponse +// @Router /cert/caProviders [get] func (s *CertService) CAProviders(w http.ResponseWriter, r *http.Request) { + Success(w, []map[string]string{ + { + "name": "Let's Encrypt", + "ca": "letsencrypt", + }, + { + "name": "ZeroSSL", + "ca": "zerossl", + }, + { + "name": "SSL.com", + "ca": "sslcom", + }, + { + "name": "Google", + "ca": "google", + }, + { + "name": "Buypass", + "ca": "buypass", + }, + }) } +// DNSProviders +// +// @Summary 获取 DNS 提供商 +// @Tags 证书服务 +// @Produce json +// @Success 200 {object} SuccessResponse +// @Router /cert/dnsProviders [get] func (s *CertService) DNSProviders(w http.ResponseWriter, r *http.Request) { + Success(w, []map[string]any{ + { + "name": "DNSPod", + "dns": acme.DnsPod, + }, + { + "name": "腾讯云", + "dns": acme.Tencent, + }, + { + "name": "阿里云", + "dns": acme.AliYun, + }, + { + "name": "CloudFlare", + "dns": acme.CloudFlare, + }, + }) } +// Algorithms +// +// @Summary 获取算法列表 +// @Tags 证书服务 +// @Produce json +// @Success 200 {object} SuccessResponse +// @Router /cert/algorithms [get] func (s *CertService) Algorithms(w http.ResponseWriter, r *http.Request) { + Success(w, []map[string]any{ + { + "name": "EC256", + "key": acme.KeyEC256, + }, + { + "name": "EC384", + "key": acme.KeyEC384, + }, + { + "name": "RSA2048", + "key": acme.KeyRSA2048, + }, + { + "name": "RSA4096", + "key": acme.KeyRSA4096, + }, + }) } +// List +// +// @Summary 证书列表 +// @Tags 证书服务 +// @Produce json +// @Success 200 {object} SuccessResponse +// @Router /cert/cert [get] func (s *CertService) List(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.Paginate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + certs, total, err := s.certRepo.List(req.Page, req.Limit) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + Success(w, chix.M{ + "total": total, + "items": certs, + }) } +// Create +// +// @Summary 创建证书 +// @Tags 证书服务 +// @Produce json +// @Success 200 {object} SuccessResponse +// @Router /cert/cert [post] func (s *CertService) Create(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.CertCreate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + cert, err := s.certRepo.Create(req) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, cert) } +// Update +// +// @Summary 更新证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/cert/{id} [post] func (s *CertService) Update(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.CertUpdate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if err = s.certRepo.Update(req); err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + Success(w, nil) } +// Get +// +// @Summary 获取证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/cert/{id} [get] func (s *CertService) Get(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + cert, err := s.certRepo.Get(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, cert) } +// Delete +// +// @Summary 删除证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/cert/{id} [delete] func (s *CertService) Delete(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + err = s.certRepo.Delete(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, nil) } +// Obtain +// +// @Summary 签发证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/{id}/obtain [post] func (s *CertService) Obtain(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + cert, err := s.certRepo.Get(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if cert.DNS != nil || cert.Website != nil { + _, err = s.certRepo.ObtainAuto(req.ID) + } else { + _, err = s.certRepo.ObtainManual(req.ID) + } + + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + Success(w, nil) } +// Renew +// +// @Summary 续签证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/{id}/renew [post] func (s *CertService) Renew(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + _, err = s.certRepo.Renew(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + Success(w, nil) } +// ManualDNS +// +// @Summary 手动 DNS +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/{id}/manualDNS [post] func (s *CertService) ManualDNS(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + dns, err := s.certRepo.ManualDNS(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, dns) } +// Deploy +// +// @Summary 部署证书 +// @Tags 证书服务 +// @Produce json +// @Param id path int true "证书 ID" +// @Param websiteID query int true "网站 ID" +// @Success 200 {object} SuccessResponse +// @Router /cert/{id}/deploy [post] func (s *CertService) Deploy(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.CertDeploy](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + err = s.certRepo.Deploy(req.ID, req.WebsiteID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, nil) } diff --git a/internal/service/task.go b/internal/service/task.go index 615adfac37..a055ffccad 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -1,12 +1,24 @@ package service -import "net/http" +import ( + "net/http" + + "github.com/go-rat/chix" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/data" + "github.com/TheTNB/panel/internal/http/request" + "github.com/TheTNB/panel/pkg/shell" +) type TaskService struct { + taskRepo biz.TaskRepo } func NewTaskService() *TaskService { - return &TaskService{} + return &TaskService{ + taskRepo: data.NewTaskRepo(), + } } // Status @@ -18,10 +30,12 @@ func NewTaskService() *TaskService { // @Success 200 {object} SuccessResponse // @Router /tasks/status [get] func (s *TaskService) Status(w http.ResponseWriter, r *http.Request) { - + Success(w, chix.M{ + "task": s.taskRepo.HasRunningTask(), + }) } -// Index +// List // // @Summary 任务列表 // @Tags 任务服务 @@ -29,11 +43,26 @@ func (s *TaskService) Status(w http.ResponseWriter, r *http.Request) { // @Produce json // @Success 200 {object} SuccessResponse // @Router /tasks [get] -func (s *TaskService) Index(w http.ResponseWriter, r *http.Request) { +func (s *TaskService) List(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.Paginate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + tasks, total, err := s.taskRepo.List(req.Page, req.Limit) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, chix.M{ + "total": total, + "items": tasks, + }) } -// Show +// Get // // @Summary 任务详情 // @Tags 任务服务 @@ -41,8 +70,25 @@ func (s *TaskService) Index(w http.ResponseWriter, r *http.Request) { // @Produce json // @Success 200 {object} SuccessResponse // @Router /task/log [get] -func (s *TaskService) Show(w http.ResponseWriter, r *http.Request) { +func (s *TaskService) Get(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + task, err := s.taskRepo.Get(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + log, err := shell.Execf(`tail -n 500 '` + task.Log + `'`) + if err == nil { + task.Log = log + } + + Success(w, task) } // Delete @@ -54,5 +100,17 @@ func (s *TaskService) Show(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} SuccessResponse // @Router /task/delete [post] func (s *TaskService) Delete(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + err = s.taskRepo.Delete(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + Success(w, nil) }