mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 06:47:20 +08:00
404 lines
9.7 KiB
Go
404 lines
9.7 KiB
Go
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 存储所有待验证的 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()
|
||
}
|
||
|
||
// 否则使用 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"`
|
||
}
|