fix: 支持面板域名 ACME 证书

This commit is contained in:
耗子
2026-08-12 15:57:43 +08:00
parent a37a0d7d75
commit d3ea65baf3
8 changed files with 120 additions and 62 deletions
+15 -3
View File
@@ -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 {
+76
View File
@@ -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 的错误")
}
})
}
+3 -3
View File
@@ -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
}
+6 -15
View File
@@ -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
View File
@@ -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 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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
}