2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00
Files
panel/pkg/acme/solvers.go
2026-01-08 18:17:09 +08:00

404 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package acme
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"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
server *http.Server
// tokens 存储所有待验证的 challengekey 为路径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()
}
// 否则使用 nginx 配置
s.useBuiltin = false
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()), 0644); 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
}
// 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
}
// 清理 nginx 配置
if err := os.WriteFile(s.conf, []byte(""), 0644); err != nil {
return fmt.Errorf("failed to write to nginx config %q: %w", s.conf, err)
}
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
}
func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error {
conf := fmt.Sprintf(`location = %s {
default_type text/plain;
return 200 %q;
}
`, challenge.HTTP01ResourcePath(), challenge.KeyAuthorization)
file, err := os.OpenFile(s.conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
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
}
// CleanUp cleans up the HTTP server if it is the last one to finish.
func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) 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;
}
`, challenge.HTTP01ResourcePath(), challenge.KeyAuthorization)
newConf := strings.ReplaceAll(string(conf), target, "")
if err = os.WriteFile(s.conf, []byte(newConf), 0644); 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
}
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,
TTL: 10 * time.Minute,
}
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
}
type manualDNSSolver struct {
check bool // 是否检查 DNS 解析,目前没写
controlChan chan struct{}
dnsChan chan any
certChan chan any
records []DNSRecord
}
func (s *manualDNSSolver) Present(ctx context.Context, challenge acme.Challenge) error {
full := challenge.DNS01TXTRecordName()
keyAuth := challenge.DNS01KeyAuthorization()
domain, err := publicsuffix.EffectiveTLDPlusOne(full)
if err != nil {
return fmt.Errorf("failed to get the effective TLD+1 for %q: %w", full, err)
}
s.records = append(s.records, DNSRecord{
Name: strings.TrimSuffix(full, "."+domain),
Domain: domain,
Value: keyAuth,
})
s.dnsChan <- s.records
select {
case <-s.controlChan:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *manualDNSSolver) CleanUp(_ context.Context, _ acme.Challenge) error {
defer func() { _ = recover() }()
close(s.controlChan)
close(s.dnsChan)
close(s.certChan)
return nil
}
type DNSRecord struct {
Name string `json:"name"`
Domain string `json:"domain"`
Value string `json:"value"`
}