mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-29 02:10:58 +08:00
fix: 支持面板域名 ACME 证书
This commit is contained in:
+15
-3
@@ -54,7 +54,7 @@ type CertRepo interface {
|
||||
GetClient(cert *Cert) (*acme.Client, error)
|
||||
GenerateSelfSigned(domains []string) ([]byte, []byte, error)
|
||||
RunScript(cert *Cert) error
|
||||
ObtainPanel(account *CertAccount, ips []string, webServer string) ([]byte, []byte, error)
|
||||
ObtainPanel(account *CertAccount, names []string, webServer string) ([]byte, []byte, error)
|
||||
LoadWebsite(WebsiteID uint) (*Website, error)
|
||||
WriteCertFiles(cert *Cert, certPath, keyPath string) error
|
||||
EnableWebsiteSSL(website *Website, certPath, keyPath, webServer string, tlsVersions []string) error
|
||||
@@ -240,9 +240,21 @@ func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id ui
|
||||
return &ssl, nil
|
||||
}
|
||||
|
||||
func (uc *CertUsecase) ObtainPanel(account *CertAccount, ips []string) ([]byte, []byte, error) {
|
||||
func (uc *CertUsecase) ObtainPanel(account *CertAccount, domains []string) ([]byte, []byte, error) {
|
||||
names := domains
|
||||
if len(names) == 0 {
|
||||
var err error
|
||||
names, err = uc.setting.GetSlice(SettingKeyPublicIPs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, nil, errors.New(uc.t.Get("Please set the panel IP in settings first for ACME certificate generation"))
|
||||
}
|
||||
}
|
||||
|
||||
webServer, _ := uc.setting.Get(SettingKeyWebserver)
|
||||
return uc.repo.ObtainPanel(account, ips, webServer)
|
||||
return uc.repo.ObtainPanel(account, names, webServer)
|
||||
}
|
||||
|
||||
func (uc *CertUsecase) ObtainSelfSigned(id uint) error {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package biz_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
mockbiz "github.com/acepanel/panel/v3/mocks/biz"
|
||||
)
|
||||
|
||||
func TestCertUsecase_ObtainPanel(t *testing.T) {
|
||||
t.Run("绑定域名时优先签发域名证书", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &biz.CertAccount{}
|
||||
domains := []string{"panel.example.com"}
|
||||
certRepo := mockbiz.NewCertRepo(t)
|
||||
settingRepo := mockbiz.NewSettingRepo(t)
|
||||
settingRepo.EXPECT().Get(biz.SettingKeyWebserver).Return("nginx", nil)
|
||||
certRepo.EXPECT().ObtainPanel(account, domains, "nginx").Return([]byte("cert"), []byte("key"), nil)
|
||||
|
||||
uc, err := biz.NewCertUsecase(gotext.NewLocale("", "en"), slog.New(slog.NewTextHandler(io.Discard, nil)), certRepo, settingRepo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
crt, key, err := uc.ObtainPanel(account, domains)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(crt, []byte("cert")) || !bytes.Equal(key, []byte("key")) {
|
||||
t.Fatalf("证书返回值不匹配: crt=%q key=%q", crt, key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未绑定域名时回退签发IP证书", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &biz.CertAccount{}
|
||||
ips := []string{"192.0.2.1"}
|
||||
certRepo := mockbiz.NewCertRepo(t)
|
||||
settingRepo := mockbiz.NewSettingRepo(t)
|
||||
settingRepo.EXPECT().GetSlice(biz.SettingKeyPublicIPs).Return(ips, nil)
|
||||
settingRepo.EXPECT().Get(biz.SettingKeyWebserver).Return("apache", nil)
|
||||
certRepo.EXPECT().ObtainPanel(account, ips, "apache").Return([]byte("cert"), []byte("key"), nil)
|
||||
|
||||
uc, err := biz.NewCertUsecase(gotext.NewLocale("", "en"), slog.New(slog.NewTextHandler(io.Discard, nil)), certRepo, settingRepo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err = uc.ObtainPanel(account, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("域名和公网IP均为空时返回错误", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
settingRepo := mockbiz.NewSettingRepo(t)
|
||||
settingRepo.EXPECT().GetSlice(biz.SettingKeyPublicIPs).Return(nil, nil)
|
||||
|
||||
uc, err := biz.NewCertUsecase(gotext.NewLocale("", "en"), slog.New(slog.NewTextHandler(io.Discard, nil)), mockbiz.NewCertRepo(t), settingRepo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err = uc.ObtainPanel(&biz.CertAccount{}, nil); err == nil {
|
||||
t.Fatal("预期返回缺少面板公网 IP 的错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func (r *certRepo) GenerateSelfSigned(domains []string) ([]byte, []byte, error)
|
||||
return pkgcert.GenerateSelfSigned(domains)
|
||||
}
|
||||
|
||||
func (r *certRepo) ObtainPanel(account *biz.CertAccount, ips []string, webServer string) ([]byte, []byte, error) {
|
||||
func (r *certRepo) ObtainPanel(account *biz.CertAccount, names []string, webServer string) ([]byte, []byte, error) {
|
||||
client, err := acme.NewPrivateKeyAccount(account.Email, account.PrivateKey, acme.CALetsEncrypt, nil, r.log)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -153,11 +153,11 @@ func (r *certRepo) ObtainPanel(account *biz.CertAccount, ips []string, webServer
|
||||
if webServer == "apache" {
|
||||
confPath = filepath.Join(app.Root, "server/apache/conf/extra/acme.conf")
|
||||
}
|
||||
client.UsePanel(ips, confPath, webServer)
|
||||
client.UsePanel(confPath, webServer)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
ssl, err := client.ObtainIPCertificate(ctx, ips, acme.KeyEC256)
|
||||
ssl, err := client.ObtainCertificate(ctx, names, acme.KeyEC256)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -123,8 +122,11 @@ func (r *CertRenew) Run(_ context.Context) error {
|
||||
crt, _ := os.ReadFile(filepath.Join(app.Root, "panel/storage/cert.pem"))
|
||||
decode, err := pkgcert.ParseCert(crt)
|
||||
if err == nil {
|
||||
// 结束时间大于 2 天不续签
|
||||
if time.Until(decode.NotAfter) > 24*2*time.Hour {
|
||||
renewBefore := 2 * 24 * time.Hour
|
||||
if len(r.conf.HTTP.BindDomain) > 0 {
|
||||
renewBefore = 30 * 24 * time.Hour
|
||||
}
|
||||
if time.Until(decode.NotAfter) > renewBefore {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
@@ -132,17 +134,6 @@ func (r *CertRenew) Run(_ context.Context) error {
|
||||
r.log.Warn("failed to parse panel certificate", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
}
|
||||
|
||||
ip, err := r.settingRepo.Get(biz.SettingKeyPublicIPs)
|
||||
if err != nil {
|
||||
r.log.Warn("failed to get panel IP", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
return nil
|
||||
}
|
||||
var ips []string
|
||||
if err = json.Unmarshal([]byte(ip), &ips); err != nil || len(ips) == 0 {
|
||||
r.log.Warn("panel public IPs not set", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
var user biz.User
|
||||
if err = r.db.First(&user).Error; err != nil {
|
||||
r.log.Warn("failed to get a panel user", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
@@ -154,7 +145,7 @@ func (r *CertRenew) Run(_ context.Context) error {
|
||||
r.notifyFailed(r.t.Get("panel certificate"), err)
|
||||
return nil
|
||||
}
|
||||
crt, key, err := r.certRepo.ObtainPanel(account, ips)
|
||||
crt, key, err := r.certRepo.ObtainPanel(account, r.conf.HTTP.BindDomain)
|
||||
if err != nil {
|
||||
r.log.Warn("failed to obtain panel certificate via ACME", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
r.notifyFailed(r.t.Get("panel certificate"), err)
|
||||
|
||||
+1
-10
@@ -435,15 +435,6 @@ func (s *CliService) HTTPSGenerate(ctx context.Context, cmd *cli.Command) error
|
||||
}
|
||||
default:
|
||||
// ACME 模式
|
||||
ip, err := s.settingRepo.Get(biz.SettingKeyPublicIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ips []string
|
||||
if err = json.Unmarshal([]byte(ip), &ips); err != nil || len(ips) == 0 {
|
||||
return errors.New(s.t.Get("Please set the panel IP in settings first for ACME certificate generation"))
|
||||
}
|
||||
|
||||
var user biz.User
|
||||
if err = s.db.First(&user).Error; err != nil {
|
||||
return errors.New(s.t.Get("Failed to get a panel user: %v", err))
|
||||
@@ -452,7 +443,7 @@ func (s *CliService) HTTPSGenerate(ctx context.Context, cmd *cli.Command) error
|
||||
if err != nil {
|
||||
return errors.New(s.t.Get("Failed to get ACME account: %v", err))
|
||||
}
|
||||
crt, key, err = s.certRepo.ObtainPanel(account, ips)
|
||||
crt, key, err = s.certRepo.ObtainPanel(account, s.conf.HTTP.BindDomain)
|
||||
if err == nil {
|
||||
fmt.Println(s.t.Get("Successfully obtained panel certificate via ACME"))
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
@@ -90,21 +89,6 @@ func (s *SettingService) ObtainCert(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ACME 模式
|
||||
ip, err := s.settingRepo.Get(biz.SettingKeyPublicIPs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
var ips []string
|
||||
if err = json.Unmarshal([]byte(ip), &ips); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("please set public ips first"))
|
||||
return
|
||||
}
|
||||
|
||||
var user biz.User
|
||||
if err = s.db.First(&user).Error; err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
@@ -116,7 +100,7 @@ func (s *SettingService) ObtainCert(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
crt, key, err := s.certRepo.ObtainPanel(account, ips)
|
||||
crt, key, err := s.certRepo.ObtainPanel(account, conf.HTTP.BindDomain)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
|
||||
+1
-3
@@ -61,13 +61,11 @@ func (c *Client) UseHTTP(conf string, webServer string) {
|
||||
}
|
||||
|
||||
// UsePanel 使用面板 HTTP 验证
|
||||
// ip 外网访问 IP 地址
|
||||
// conf 配置文件路径
|
||||
// webServer web 服务器类型 ("nginx" 或 "apache")
|
||||
func (c *Client) UsePanel(ip []string, conf string, webServer string) {
|
||||
func (c *Client) UsePanel(conf string, webServer string) {
|
||||
c.zClient.ChallengeSolvers = map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &panelSolver{
|
||||
ip: ip,
|
||||
conf: conf,
|
||||
webServer: webServer,
|
||||
},
|
||||
|
||||
+17
-11
@@ -35,7 +35,7 @@ import (
|
||||
var panelSolverGlobal sync.Mutex
|
||||
|
||||
type panelSolver struct {
|
||||
ip []string
|
||||
names []string
|
||||
conf string
|
||||
webServer string // "nginx" or "apache"
|
||||
server *http.Server
|
||||
@@ -64,8 +64,11 @@ func (s *panelSolver) Present(_ context.Context, challenge acme.Challenge) error
|
||||
|
||||
// 收集所有域名的 token
|
||||
s.tokens[path] = token
|
||||
s.names = append(s.names, challenge.Identifier.Value)
|
||||
s.presentCount++
|
||||
if s.presentCount < len(s.ip) {
|
||||
|
||||
// 内置服务器启动后只需继续追加 token
|
||||
if s.server != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -116,7 +119,7 @@ func (s *panelSolver) startServer() error {
|
||||
}
|
||||
|
||||
func (s *panelSolver) writeNginxConfig() error {
|
||||
hasIPv6 := lo.SomeBy(s.ip, tools.IsIPv6)
|
||||
hasIPv6 := lo.SomeBy(s.names, tools.IsIPv6)
|
||||
|
||||
var conf strings.Builder
|
||||
conf.WriteString("server {\n listen 80;\n")
|
||||
@@ -124,8 +127,8 @@ func (s *panelSolver) writeNginxConfig() error {
|
||||
if hasIPv6 {
|
||||
conf.WriteString(" listen [::]:80;\n")
|
||||
}
|
||||
names := lo.Map(s.ip, func(ip string, _ int) string {
|
||||
return tools.WrapIPv6(ip)
|
||||
names := lo.Map(s.names, func(name string, _ int) string {
|
||||
return tools.WrapIPv6(name)
|
||||
})
|
||||
_, _ = fmt.Fprintf(&conf, " server_name %s;\n", strings.Join(names, " "))
|
||||
for path, token := range s.tokens {
|
||||
@@ -162,11 +165,14 @@ func (s *panelSolver) writeApacheConfig() error {
|
||||
}
|
||||
|
||||
var conf strings.Builder
|
||||
addrs := lo.Map(s.ip, func(ip string, _ int) string {
|
||||
return tools.WrapIPv6(ip) + ":80"
|
||||
names := lo.Map(s.names, func(name string, _ int) string {
|
||||
return tools.WrapIPv6(name)
|
||||
})
|
||||
_, _ = fmt.Fprintf(&conf, "<VirtualHost %s>\n", strings.Join(addrs, " "))
|
||||
conf.WriteString(" ServerName acme-ip-validation\n")
|
||||
conf.WriteString("<VirtualHost *:80>\n")
|
||||
_, _ = fmt.Fprintf(&conf, " ServerName %s\n", names[0])
|
||||
if len(names) > 1 {
|
||||
_, _ = fmt.Fprintf(&conf, " ServerAlias %s\n", strings.Join(names[1:], " "))
|
||||
}
|
||||
_, _ = fmt.Fprintf(&conf, " Alias /.well-known/acme-challenge %s\n", tokenDir)
|
||||
_, _ = fmt.Fprintf(&conf, " <Directory %s>\n", tokenDir)
|
||||
conf.WriteString(" Require all granted\n")
|
||||
@@ -190,8 +196,8 @@ func (s *panelSolver) writeApacheConfig() error {
|
||||
func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error {
|
||||
s.cleanupCount++
|
||||
|
||||
// 等待最后一次 CleanUp
|
||||
if s.cleanupCount < len(s.ip) {
|
||||
// 等待所有实际执行过 Present 的验证完成
|
||||
if s.cleanupCount < s.presentCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user