package acme
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/libdns/alidns"
"github.com/libdns/cloudflare"
"github.com/libdns/cloudns"
"github.com/libdns/gcore"
"github.com/libdns/huaweicloud"
"github.com/libdns/libdns"
"github.com/libdns/namesilo"
"github.com/libdns/porkbun"
"github.com/libdns/tencentcloud"
"github.com/libdns/westcn"
"github.com/mholt/acmez/v3/acme"
"golang.org/x/net/publicsuffix"
pkgos "github.com/acepanel/panel/pkg/os"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
)
var panelSolverGlobal sync.Mutex
type panelSolver struct {
ip []string
conf string
webServer string // "nginx" or "apache"
server *http.Server
// tokens 存储所有待验证的 challenge,key 为路径,value 为 token
tokens map[string]string
// presentCount Present 调用计数
presentCount int
// cleanupCount CleanUp 调用计数
cleanupCount int
// useBuiltin 标记是否使用内置 HTTP 服务器
useBuiltin bool
}
func (s *panelSolver) Present(_ context.Context, challenge acme.Challenge) error {
if s.presentCount == 0 {
panelSolverGlobal.Lock()
}
path := challenge.HTTP01ResourcePath()
token := challenge.KeyAuthorization
// 初始化 tokens map
if s.tokens == nil {
s.tokens = make(map[string]string)
}
// 收集所有域名的 token
s.tokens[path] = token
s.presentCount++
if s.presentCount < len(s.ip) {
return nil
}
// 如果 80 端口没有被占用,则使用内置的 HTTP 服务器
if !pkgos.TCPPortInUse(80) {
s.useBuiltin = true
return s.startServer()
}
// 否则使用 web 服务器配置
s.useBuiltin = false
if s.webServer == "apache" {
return s.writeApacheConfig()
}
return s.writeNginxConfig()
}
func (s *panelSolver) startServer() error {
s.server = &http.Server{
Addr: ":80",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := s.tokens[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(token))
}),
}
errChan := make(chan error, 1)
go func() {
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errChan <- err
}
close(errChan)
}()
// 等待一小段时间确保服务器启动成功
select {
case err := <-errChan:
s.server = nil
return fmt.Errorf("failed to start HTTP server: %w", err)
case <-time.After(100 * time.Millisecond):
return nil
}
}
func (s *panelSolver) writeNginxConfig() error {
var conf strings.Builder
conf.WriteString(fmt.Sprintf("server {\n listen 80;\n server_name %s;\n", strings.Join(s.ip, " ")))
for path, token := range s.tokens {
conf.WriteString(fmt.Sprintf(" location = %s {\n default_type text/plain;\n return 200 %q;\n }\n", path, token))
}
conf.WriteString("}\n")
if err := os.WriteFile(s.conf, []byte(conf.String()), 0600); err != nil {
return fmt.Errorf("failed to write nginx config %q: %w", s.conf, err)
}
if err := systemctl.Reload("nginx"); err != nil {
_, err = shell.Execf("nginx -t")
return fmt.Errorf("failed to reload nginx: %w", err)
}
return nil
}
func (s *panelSolver) writeApacheConfig() error {
// Apache 使用 Alias 指向一个临时目录,将 token 写入文件
tokenDir := "/tmp/acme-challenge"
if err := os.MkdirAll(tokenDir, 0755); err != nil {
return fmt.Errorf("failed to create token directory: %w", err)
}
// 写入 token 文件
for path, token := range s.tokens {
// path 格式为 /.well-known/acme-challenge/xxx
tokenFile := filepath.Join(tokenDir, filepath.Base(path))
if err := os.WriteFile(tokenFile, []byte(token), 0644); err != nil {
return fmt.Errorf("failed to write token file: %w", err)
}
}
var conf strings.Builder
conf.WriteString(fmt.Sprintf("\n ServerName %s\n", s.ip[0]))
if len(s.ip) > 1 {
for _, ip := range s.ip[1:] {
conf.WriteString(fmt.Sprintf(" ServerAlias %s\n", ip))
}
}
conf.WriteString(fmt.Sprintf(" Alias /.well-known/acme-challenge %s\n", tokenDir))
conf.WriteString(fmt.Sprintf(" \n", tokenDir))
conf.WriteString(" Require all granted\n")
conf.WriteString(" ForceType text/plain\n")
conf.WriteString(" \n")
conf.WriteString("\n")
if err := os.WriteFile(s.conf, []byte(conf.String()), 0600); err != nil {
return fmt.Errorf("failed to write apache config %q: %w", s.conf, err)
}
if err := systemctl.Reload("apache"); err != nil {
_, err = shell.Execf("apachectl -t")
return fmt.Errorf("failed to reload apache: %w", err)
}
return nil
}
// CleanUp cleans up the HTTP server on last call.
func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error {
s.cleanupCount++
// 等待最后一次 CleanUp
if s.cleanupCount < len(s.ip) {
return nil
}
defer panelSolverGlobal.Unlock()
if s.useBuiltin && s.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
}
s.server = nil
return nil
}
// 清理配置文件
if err := os.WriteFile(s.conf, []byte(""), 0600); err != nil {
return fmt.Errorf("failed to write to config %q: %w", s.conf, err)
}
// 清理 Apache token 目录
if s.webServer == "apache" {
_ = os.RemoveAll("/tmp/acme-challenge")
if err := systemctl.Reload("apache"); err != nil {
_, _ = shell.Execf("apachectl -t")
return fmt.Errorf("failed to reload apache: %w", err)
}
return nil
}
if err := systemctl.Reload("nginx"); err != nil {
_, _ = shell.Execf("nginx -t")
return fmt.Errorf("failed to reload nginx: %w", err)
}
return nil
}
type httpSolver struct {
conf string
webServer string // "nginx" or "apache"
}
func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error {
path := challenge.HTTP01ResourcePath()
token := challenge.KeyAuthorization
if s.webServer == "apache" {
return s.presentApache(path, token)
}
return s.presentNginx(path, token)
}
func (s httpSolver) presentNginx(path, token string) error {
conf := fmt.Sprintf(`location = %s {
default_type text/plain;
return 200 %q;
}
`, path, token)
file, err := os.OpenFile(s.conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open nginx config %q: %w", s.conf, err)
}
defer func(file *os.File) { _ = file.Close() }(file)
if _, err = file.Write([]byte(conf)); err != nil {
return fmt.Errorf("failed to write to nginx config %q: %w", s.conf, err)
}
if err = systemctl.Reload("nginx"); err != nil {
_, err = shell.Execf("nginx -t")
return fmt.Errorf("failed to reload nginx: %w", err)
}
return nil
}
func (s httpSolver) presentApache(path, token string) error {
// 创建 token 目录
tokenDir := filepath.Dir(s.conf) + "/acme-challenge"
if err := os.MkdirAll(tokenDir, 0755); err != nil {
return fmt.Errorf("failed to create token directory: %w", err)
}
// 写入 token 文件
tokenFile := filepath.Join(tokenDir, filepath.Base(path))
if err := os.WriteFile(tokenFile, []byte(token), 0644); err != nil {
return fmt.Errorf("failed to write token file: %w", err)
}
// 写入 Apache 配置
conf := fmt.Sprintf(`Alias /.well-known/acme-challenge %s
Require all granted
ForceType text/plain
`, tokenDir, tokenDir)
file, err := os.OpenFile(s.conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open apache config %q: %w", s.conf, err)
}
defer func(file *os.File) { _ = file.Close() }(file)
if _, err = file.Write([]byte(conf)); err != nil {
return fmt.Errorf("failed to write to apache config %q: %w", s.conf, err)
}
if err = systemctl.Reload("apache"); err != nil {
_, err = shell.Execf("apachectl -t")
return fmt.Errorf("failed to reload apache: %w", err)
}
return nil
}
// CleanUp cleans up the HTTP server if it is the last one to finish.
func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) error {
path := challenge.HTTP01ResourcePath()
token := challenge.KeyAuthorization
if s.webServer == "apache" {
return s.cleanUpApache(path, token)
}
return s.cleanUpNginx(path, token)
}
func (s httpSolver) cleanUpNginx(path, token string) error {
conf, err := os.ReadFile(s.conf)
if err != nil {
return fmt.Errorf("failed to read nginx config %q: %w", s.conf, err)
}
target := fmt.Sprintf(`location = %s {
default_type text/plain;
return 200 %q;
}
`, path, token)
newConf := strings.ReplaceAll(string(conf), target, "")
if err = os.WriteFile(s.conf, []byte(newConf), 0600); err != nil {
return fmt.Errorf("failed to write to nginx config %q: %w", s.conf, err)
}
if err = systemctl.Reload("nginx"); err != nil {
_, err = shell.Execf("nginx -t")
return fmt.Errorf("failed to reload nginx: %w", err)
}
return nil
}
func (s httpSolver) cleanUpApache(path, token string) error {
tokenDir := filepath.Dir(s.conf) + "/acme-challenge"
// 删除 token 文件
tokenFile := filepath.Join(tokenDir, filepath.Base(path))
_ = os.Remove(tokenFile)
// 清理配置文件
conf, err := os.ReadFile(s.conf)
if err != nil {
return fmt.Errorf("failed to read apache config %q: %w", s.conf, err)
}
target := fmt.Sprintf(`Alias /.well-known/acme-challenge %s
Require all granted
ForceType text/plain
`, tokenDir, tokenDir)
newConf := strings.ReplaceAll(string(conf), target, "")
if err = os.WriteFile(s.conf, []byte(newConf), 0600); err != nil {
return fmt.Errorf("failed to write to apache config %q: %w", s.conf, err)
}
if err = systemctl.Reload("apache"); err != nil {
_, err = shell.Execf("apachectl -t")
return fmt.Errorf("failed to reload apache: %w", err)
}
return nil
}
type dnsSolver struct {
dns DnsType
param DNSParam
records []libdns.Record
}
func (s *dnsSolver) Present(ctx context.Context, challenge acme.Challenge) error {
dnsName := challenge.DNS01TXTRecordName()
keyAuth := challenge.DNS01KeyAuthorization()
provider, err := s.getDNSProvider()
if err != nil {
return fmt.Errorf("failed to get DNS provider: %w", err)
}
zone, err := publicsuffix.EffectiveTLDPlusOne(dnsName)
if err != nil {
return fmt.Errorf("failed to get the effective TLD+1 for %q: %w", dnsName, err)
}
rec := libdns.TXT{
Name: libdns.RelativeName(dnsName+".", zone+"."),
Text: keyAuth,
}
results, err := provider.SetRecords(ctx, zone+".", []libdns.Record{rec})
if err != nil {
return fmt.Errorf("failed to set DNS record %q for %q: %w", dnsName, zone, err)
}
if len(results) != 1 {
return fmt.Errorf("expected to add 1 record, but actually added %d records", len(results))
}
s.records = results
return nil
}
func (s *dnsSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
dnsName := challenge.DNS01TXTRecordName()
provider, err := s.getDNSProvider()
if err != nil {
return fmt.Errorf("failed to get DNS provider: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
zone, err := publicsuffix.EffectiveTLDPlusOne(dnsName)
if err != nil {
return fmt.Errorf("failed to get the effective TLD+1 for %q: %w", dnsName, err)
}
_, _ = provider.DeleteRecords(ctx, zone+".", s.records)
return nil
}
func (s *dnsSolver) getDNSProvider() (DNSProvider, error) {
var dns DNSProvider
switch s.dns {
case AliYun:
dns = &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{
AccessKeyID: s.param.AK,
AccessKeySecret: s.param.SK,
},
}
case Tencent:
dns = &tencentcloud.Provider{
SecretId: s.param.AK,
SecretKey: s.param.SK,
}
case Huawei:
dns = &huaweicloud.Provider{
AccessKeyId: s.param.AK,
SecretAccessKey: s.param.SK,
}
case Westcn:
dns = &westcn.Provider{
Username: s.param.SK,
APIPassword: s.param.AK,
}
case CloudFlare:
dns = &cloudflare.Provider{
APIToken: s.param.AK,
}
case Gcore:
dns = &gcore.Provider{
APIKey: s.param.AK,
}
case Porkbun:
dns = &porkbun.Provider{
APIKey: s.param.AK,
APISecretKey: s.param.SK,
}
case NameSilo:
dns = &namesilo.Provider{
APIToken: s.param.AK,
}
case ClouDNS:
if strings.HasPrefix(s.param.AK, "sub-") {
dns = &cloudns.Provider{
SubAuthId: strings.TrimPrefix(s.param.AK, "sub-"),
AuthPassword: s.param.SK,
}
} else {
dns = &cloudns.Provider{
AuthId: s.param.AK,
AuthPassword: s.param.SK,
}
}
default:
return nil, fmt.Errorf("unsupported DNS provider: %s", s.dns)
}
return dns, nil
}
type DnsType string
const (
AliYun DnsType = "aliyun"
Tencent DnsType = "tencent"
Huawei DnsType = "huawei"
Westcn DnsType = "westcn"
CloudFlare DnsType = "cloudflare"
Gcore DnsType = "gcore"
Porkbun DnsType = "porkbun"
NameSilo DnsType = "namesilo"
ClouDNS DnsType = "cloudns"
)
type DNSParam struct {
AK string `form:"ak" json:"ak"`
SK string `form:"sk" json:"sk"`
}
type DNSProvider interface {
libdns.RecordSetter
libdns.RecordDeleter
}