feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
)
|
||||
|
||||
const (
|
||||
CALetsEncrypt = "letsencrypt"
|
||||
CALetsEncryptStaging = "letsencrypt-staging"
|
||||
CAZeroSSL = "zerossl"
|
||||
CABuypass = "buypass"
|
||||
// CALiteSSL 亚数 TrustAsia 的公益免费证书(90 天,支持单域名/通配符/多域名,强制 EAB)
|
||||
CALiteSSL = "litessl"
|
||||
)
|
||||
|
||||
// caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理),
|
||||
// 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。
|
||||
var caRetired = map[string]string{
|
||||
CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt、LiteSSL 或 ZeroSSL",
|
||||
}
|
||||
|
||||
// CAUnavailableReason 返回该 CA 已停用的原因;可用的 CA 返回空字符串。
|
||||
func CAUnavailableReason(ca string) string {
|
||||
return caRetired[NormalizeCA(ca)]
|
||||
}
|
||||
|
||||
const (
|
||||
zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
|
||||
buypassDirectoryURL = "https://api.buypass.com/acme/directory"
|
||||
// liteSSLDirectoryURL 实测 2026-09-27 可访问,目录里 meta.externalAccountRequired = true
|
||||
liteSSLDirectoryURL = "https://acme.litessl.com/acme/v2/directory"
|
||||
)
|
||||
|
||||
func NormalizeCA(ca string) string {
|
||||
switch ca {
|
||||
case "", CALetsEncrypt, "production":
|
||||
return CALetsEncrypt
|
||||
case CALetsEncryptStaging, "staging", "letsencrypt-test":
|
||||
return CALetsEncryptStaging
|
||||
case CAZeroSSL:
|
||||
return CAZeroSSL
|
||||
case CABuypass, "buypass-go":
|
||||
return CABuypass
|
||||
case CALiteSSL, "trustasia":
|
||||
return CALiteSSL
|
||||
default:
|
||||
return ca
|
||||
}
|
||||
}
|
||||
|
||||
func DirectoryURL(ca string) (string, error) {
|
||||
switch NormalizeCA(ca) {
|
||||
case CALetsEncrypt:
|
||||
return lego.LEDirectoryProduction, nil
|
||||
case CALetsEncryptStaging:
|
||||
return lego.LEDirectoryStaging, nil
|
||||
case CAZeroSSL:
|
||||
return zeroSSLDirectoryURL, nil
|
||||
case CABuypass:
|
||||
return buypassDirectoryURL, nil
|
||||
case CALiteSSL:
|
||||
return liteSSLDirectoryURL, nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的颁发机构:%s", ca)
|
||||
}
|
||||
}
|
||||
|
||||
func CALabel(ca string) string {
|
||||
switch NormalizeCA(ca) {
|
||||
case CALetsEncrypt:
|
||||
return "Let's Encrypt"
|
||||
case CALetsEncryptStaging:
|
||||
return "Let's Encrypt 测试"
|
||||
case CAZeroSSL:
|
||||
return "ZeroSSL"
|
||||
case CABuypass:
|
||||
return "Buypass"
|
||||
case CALiteSSL:
|
||||
return "LiteSSL"
|
||||
default:
|
||||
if ca == "" {
|
||||
return "手动导入"
|
||||
}
|
||||
return ca
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateCA(ca string) error {
|
||||
_, err := DirectoryURL(ca)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateCADomains 校验各 CA 自己的限制。
|
||||
// LiteSSL 官方支持单域名 / 通配符 / 多域名,因此这里不额外限制。
|
||||
func ValidateCADomains(ca string, domains []string) error {
|
||||
switch NormalizeCA(ca) {
|
||||
case CABuypass:
|
||||
if len(domains) > 5 {
|
||||
return fmt.Errorf("Buypass 单张证书最多支持 5 个域名")
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return fmt.Errorf("Buypass 不支持通配符证书")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package acme
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectoryURL(t *testing.T) {
|
||||
url, err := DirectoryURL(CALetsEncrypt)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("production: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CALetsEncryptStaging)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("staging: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CAZeroSSL)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("zerossl: %v", err)
|
||||
}
|
||||
url, err = DirectoryURL(CABuypass)
|
||||
if err != nil || url == "" {
|
||||
t.Fatalf("buypass: %v", err)
|
||||
}
|
||||
if err := ValidateCA("unknown"); err == nil {
|
||||
t.Fatal("expected error for unknown ca")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCADomainsBuypass(t *testing.T) {
|
||||
if err := ValidateCADomains(CABuypass, []string{"*.example.com"}); err == nil {
|
||||
t.Fatal("expected wildcard error")
|
||||
}
|
||||
if err := ValidateCADomains(CABuypass, []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}); err == nil {
|
||||
t.Fatal("expected domain limit error")
|
||||
}
|
||||
if err := ValidateCADomains(CALetsEncrypt, []string{"*.example.com"}); err != nil {
|
||||
t.Fatalf("letsencrypt wildcard should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/providers/dns/alidns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/dnspod"
|
||||
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
|
||||
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
|
||||
switch provider {
|
||||
case "dnspod":
|
||||
cfg := dnspod.NewDefaultConfig()
|
||||
cfg.LoginToken = cred.LoginToken()
|
||||
return dnspod.NewDNSProviderConfig(cfg)
|
||||
case "alidns":
|
||||
cfg := alidns.NewDefaultConfig()
|
||||
cfg.APIKey = cred.Token
|
||||
cfg.SecretKey = cred.Secret
|
||||
return alidns.NewDNSProviderConfig(cfg)
|
||||
case "tencentcloud":
|
||||
cfg := tencentcloud.NewDefaultConfig()
|
||||
cfg.SecretID = cred.Token
|
||||
cfg.SecretKey = cred.Secret
|
||||
return tencentcloud.NewDNSProviderConfig(cfg)
|
||||
case "volcengine":
|
||||
return newVolcengineDNSProvider(cred)
|
||||
default:
|
||||
cfg := cloudflare.NewDefaultConfig()
|
||||
cfg.AuthToken = cred.Token
|
||||
return cloudflare.NewDNSProviderConfig(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func dnsProviderName(provider string) string {
|
||||
switch provider {
|
||||
case "dnspod", "alidns", "tencentcloud", "volcengine":
|
||||
return provider
|
||||
default:
|
||||
return "cloudflare"
|
||||
}
|
||||
}
|
||||
|
||||
type loggingDNSProvider struct {
|
||||
inner challenge.Provider
|
||||
job *Job
|
||||
}
|
||||
|
||||
func wrapDNSProvider(inner challenge.Provider, job *Job) challenge.Provider {
|
||||
if job == nil {
|
||||
return inner
|
||||
}
|
||||
return &loggingDNSProvider{inner: inner, job: job}
|
||||
}
|
||||
|
||||
func (p *loggingDNSProvider) Present(domain, token, keyAuth string) error {
|
||||
record := strings.TrimSuffix(domain, ".")
|
||||
p.job.Info(fmt.Sprintf("添加 DNS TXT 记录: _acme-challenge.%s", record))
|
||||
if err := p.inner.Present(domain, token, keyAuth); err != nil {
|
||||
p.job.Error(fmt.Sprintf("添加 DNS TXT 记录失败: %v", err))
|
||||
return err
|
||||
}
|
||||
p.job.Info("DNS TXT 记录已添加,等待 DNS 传播…")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *loggingDNSProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
p.job.Info("清理 DNS TXT 验证记录…")
|
||||
return p.inner.CleanUp(domain, token, keyAuth)
|
||||
}
|
||||
|
||||
func validateDNSCredentials(provider string, cred ddns.Credentials) error {
|
||||
if err := cred.Validate(dnsProviderName(provider)); err != nil {
|
||||
return err
|
||||
}
|
||||
if provider == "dnspod" && cred.TokenID == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置 DNSPod ID 和 Token")
|
||||
}
|
||||
if provider == "alidns" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置阿里云 AccessKey")
|
||||
}
|
||||
if provider == "tencentcloud" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置腾讯云 SecretKey")
|
||||
}
|
||||
if provider == "volcengine" && cred.Secret == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置火山引擎 Secret Access Key")
|
||||
}
|
||||
if provider != "dnspod" && provider != "alidns" && provider != "tencentcloud" && provider != "volcengine" && cred.Token == "" {
|
||||
return fmt.Errorf("请先在 DDNS 页面配置 Cloudflare API Token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
func TestValidateDNSCredentialsVolcengine(t *testing.T) {
|
||||
if err := validateDNSCredentials("volcengine", ddns.Credentials{Token: "ak", Secret: "sk"}); err != nil {
|
||||
t.Fatalf("expected volcengine credentials to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVolcengineDNSProvider(t *testing.T) {
|
||||
p, err := newVolcengineDNSProvider(ddns.Credentials{Token: "ak", Secret: "sk"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("expected provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
func NormalizeCertDomains(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
if len(raw) > 100 {
|
||||
return nil, fmt.Errorf("域名数量不能超过 100 个")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for _, part := range splitDomainInput(item) {
|
||||
domain, err := normalizeCertDomain(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[domain] {
|
||||
continue
|
||||
}
|
||||
seen[domain] = true
|
||||
out = append(out, domain)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitDomainInput(input string) []string {
|
||||
input = strings.ReplaceAll(input, "\r\n", "\n")
|
||||
input = strings.ReplaceAll(input, ",", "\n")
|
||||
parts := strings.Split(input, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCertDomain(domain string) (string, error) {
|
||||
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("域名不能为空")
|
||||
}
|
||||
wildcard := strings.HasPrefix(domain, "*.")
|
||||
base := strings.TrimPrefix(domain, "*.")
|
||||
if err := validate.Domain(base); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if wildcard {
|
||||
return "*." + base, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func DomainsUnderZone(domains []string, zone string) error {
|
||||
return DomainsUnderZones(domains, []string{zone})
|
||||
}
|
||||
|
||||
func DomainsUnderZones(domains []string, zones []string) error {
|
||||
normalized := normalizeManagedZones(zones)
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("请选择 DNS 凭证")
|
||||
}
|
||||
for _, domain := range domains {
|
||||
base := strings.TrimPrefix(domain, "*.")
|
||||
if !domainUnderAnyZone(base, normalized) {
|
||||
return fmt.Errorf("域名 %s 不在所选 DNS 凭证的管理范围内(%s)", domain, strings.Join(normalized, "、"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeManagedZones(zones []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(zones))
|
||||
for _, zone := range zones {
|
||||
zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
|
||||
if zone == "" || seen[zone] {
|
||||
continue
|
||||
}
|
||||
seen[zone] = true
|
||||
out = append(out, zone)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func domainUnderAnyZone(domain string, zones []string) bool {
|
||||
for _, zone := range zones {
|
||||
if domain == zone || strings.HasSuffix(domain, "."+zone) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PrimaryCertDomain(domains []string) string {
|
||||
for _, domain := range domains {
|
||||
if !strings.HasPrefix(domain, "*.") {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
if len(domains) > 0 {
|
||||
return strings.TrimPrefix(domains[0], "*.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func HasWildcardDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func LegacyCertDomains(domain string, wildcard bool) []string {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
if wildcard {
|
||||
return []string{domain, "*." + domain}
|
||||
}
|
||||
return []string{domain}
|
||||
}
|
||||
|
||||
func EncodeCertDomains(domains []string) string {
|
||||
if len(domains) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(domains)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeCertDomains(raw string, domain string, wildcard bool) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err == nil && len(domains) > 0 {
|
||||
normalized, err := NormalizeCertDomains(domains)
|
||||
if err == nil {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return LegacyCertDomains(domain, wildcard)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package acme
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeCertDomains(t *testing.T) {
|
||||
domains, err := NormalizeCertDomains([]string{"example.com", "*.example.com", "nas.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(domains) != 3 {
|
||||
t.Fatalf("expected 3 domains, got %d", len(domains))
|
||||
}
|
||||
if domains[1] != "*.example.com" {
|
||||
t.Fatalf("unexpected wildcard: %s", domains[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainsUnderZone(t *testing.T) {
|
||||
domains := []string{"example.com", "*.example.com", "nas.example.com"}
|
||||
if err := DomainsUnderZone(domains, "example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DomainsUnderZone([]string{"other.com"}, "example.com"); err == nil {
|
||||
t.Fatal("expected zone mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainsUnderZonesMultiRoot(t *testing.T) {
|
||||
zones := []string{"roven.cc", "chiak.cc"}
|
||||
if err := DomainsUnderZones([]string{"chiak.cc", "*.chiak.cc"}, zones); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DomainsUnderZones([]string{"other.com"}, zones); err == nil {
|
||||
t.Fatal("expected zone mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCertDomainsLegacy(t *testing.T) {
|
||||
got := DecodeCertDomains("", "example.com", true)
|
||||
if len(got) != 2 || got[1] != "*.example.com" {
|
||||
t.Fatalf("unexpected legacy domains: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
)
|
||||
|
||||
type DownloadPart string
|
||||
|
||||
const (
|
||||
DownloadZIP DownloadPart = "zip"
|
||||
DownloadCert DownloadPart = "cert"
|
||||
DownloadKey DownloadPart = "key"
|
||||
)
|
||||
|
||||
func (s *Service) Download(ctx context.Context, domain string, part string) (filename string, contentType string, data []byte, err error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return "", "", nil, fmt.Errorf("域名不能为空")
|
||||
}
|
||||
|
||||
rec, err := s.store.GetByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if rec.CertPath == "" || rec.KeyPath == "" {
|
||||
return "", "", nil, fmt.Errorf("证书文件不存在,可能尚未申请成功")
|
||||
}
|
||||
if err := validateCertFilePath(s.cfg.CertsDir(), rec.CertPath); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if err := validateCertFilePath(s.cfg.CertsDir(), rec.KeyPath); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(rec.CertPath)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("读取证书失败")
|
||||
}
|
||||
keyPEM, err := os.ReadFile(rec.KeyPath)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("读取私钥失败")
|
||||
}
|
||||
|
||||
base := downloadBaseName(rec)
|
||||
switch DownloadPart(strings.ToLower(strings.TrimSpace(part))) {
|
||||
case "", DownloadZIP:
|
||||
data, err = zipCertBundle(base, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return base + ".zip", "application/zip", data, nil
|
||||
case DownloadCert:
|
||||
return base + "-fullchain.pem", "application/x-pem-file", certPEM, nil
|
||||
case DownloadKey:
|
||||
return base + "-privatekey.pem", "application/x-pem-file", keyPEM, nil
|
||||
default:
|
||||
return "", "", nil, fmt.Errorf("不支持的下载类型")
|
||||
}
|
||||
}
|
||||
|
||||
func downloadBaseName(rec certstore.Record) string {
|
||||
name := strings.TrimPrefix(rec.Domain, "*.")
|
||||
name = strings.ReplaceAll(name, "*", "wildcard")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
if name == "" {
|
||||
name = "certificate"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func validateCertFilePath(certsDir, path string) error {
|
||||
absCerts, err := filepath.Abs(certsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("证书目录无效")
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("证书路径无效")
|
||||
}
|
||||
rel, err := filepath.Rel(absCerts, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("证书路径无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zipCertBundle(base string, certPEM, keyPEM []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := writeZipEntry(zw, "fullchain.pem", certPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeZipEntry(zw, "privatekey.pem", keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCertFilePath(t *testing.T) {
|
||||
certsDir := filepath.Clean("/data/certs")
|
||||
if err := validateCertFilePath(certsDir, "/data/certs/example.com/fullchain.pem"); err != nil {
|
||||
t.Fatalf("expected valid path: %v", err)
|
||||
}
|
||||
if err := validateCertFilePath(certsDir, "/etc/passwd"); err == nil {
|
||||
t.Fatal("expected invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipCertBundle(t *testing.T) {
|
||||
data, err := zipCertBundle("example.com", []byte("cert"), []byte("key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("empty zip")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
)
|
||||
|
||||
type JobEvent struct {
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Result *JobDonePayload `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type JobDonePayload struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
CertDir string `json:"cert_dir,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
id string
|
||||
mu sync.Mutex
|
||||
events []JobEvent
|
||||
done bool
|
||||
waiters map[chan JobEvent]struct{}
|
||||
}
|
||||
|
||||
type JobManager struct {
|
||||
mu sync.Mutex
|
||||
jobs map[string]*Job
|
||||
}
|
||||
|
||||
func NewJobManager() *JobManager {
|
||||
return &JobManager{jobs: map[string]*Job{}}
|
||||
}
|
||||
|
||||
func (m *JobManager) Create() *Job {
|
||||
job := &Job{
|
||||
id: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
waiters: map[chan JobEvent]struct{}{},
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.jobs[job.id] = job
|
||||
m.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
|
||||
func (m *JobManager) Get(id string) *Job {
|
||||
m.mu.Lock()
|
||||
job := m.jobs[id]
|
||||
m.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
|
||||
func (j *Job) ID() string {
|
||||
return j.id
|
||||
}
|
||||
|
||||
func (j *Job) Append(level, message string) {
|
||||
j.emit(JobEvent{Type: "log", Level: level, Message: message})
|
||||
}
|
||||
|
||||
func (j *Job) Info(message string) { j.Append("info", message) }
|
||||
func (j *Job) Warn(message string) { j.Append("warn", message) }
|
||||
func (j *Job) Error(message string) { j.Append("error", message) }
|
||||
|
||||
func (j *Job) Finish(result JobDonePayload) {
|
||||
j.emit(JobEvent{Type: "done", Result: &result})
|
||||
}
|
||||
|
||||
func (j *Job) emit(ev JobEvent) {
|
||||
j.mu.Lock()
|
||||
j.events = append(j.events, ev)
|
||||
if ev.Type == "done" {
|
||||
j.done = true
|
||||
}
|
||||
for ch := range j.waiters {
|
||||
ch <- ev
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
func (j *Job) Snapshot() []JobEvent {
|
||||
j.mu.Lock()
|
||||
out := append([]JobEvent(nil), j.events...)
|
||||
j.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (j *Job) Subscribe() chan JobEvent {
|
||||
ch := make(chan JobEvent, 32)
|
||||
j.mu.Lock()
|
||||
j.waiters[ch] = struct{}{}
|
||||
j.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (j *Job) Unsubscribe(ch chan JobEvent) {
|
||||
j.mu.Lock()
|
||||
delete(j.waiters, ch)
|
||||
j.mu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
|
||||
job := m.Get(id)
|
||||
if job == nil {
|
||||
return fmt.Errorf("任务不存在或已过期")
|
||||
}
|
||||
|
||||
ch := job.Subscribe()
|
||||
defer job.Unsubscribe(ch)
|
||||
|
||||
for _, ev := range job.Snapshot() {
|
||||
if err := writeJobSSE(w, ev); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Type == "done" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case ev := <-ch:
|
||||
if err := writeJobSSE(w, ev); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Type == "done" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJobSSE(w io.Writer, ev JobEvent) error {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
|
||||
if err != nil {
|
||||
return JobDonePayload{OK: false, Error: err.Error()}
|
||||
}
|
||||
payload := JobDonePayload{
|
||||
OK: true,
|
||||
Domain: rec.Domain,
|
||||
CertPath: rec.CertPath,
|
||||
KeyPath: rec.KeyPath,
|
||||
}
|
||||
if rec.CertPath != "" {
|
||||
payload.CertDir = filepath.Dir(rec.CertPath)
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// LiteSSL 接替 Buypass 的位置:目录地址实测可用,且强制 EAB。
|
||||
func TestLiteSSLDirectoryAndLabel(t *testing.T) {
|
||||
url, err := DirectoryURL(CALiteSSL)
|
||||
if err != nil {
|
||||
t.Fatalf("LiteSSL 目录地址解析失败:%v", err)
|
||||
}
|
||||
if url != "https://acme.litessl.com/acme/v2/directory" {
|
||||
t.Fatalf("LiteSSL 目录地址不对:%q", url)
|
||||
}
|
||||
if label := CALabel(CALiteSSL); label != "LiteSSL" {
|
||||
t.Fatalf("LiteSSL 展示名不对:%q", label)
|
||||
}
|
||||
// 别名与归一化
|
||||
if got := NormalizeCA("trustasia"); got != CALiteSSL {
|
||||
t.Fatalf("别名 trustasia 应归一化为 %s,实际 %q", CALiteSSL, got)
|
||||
}
|
||||
if reason := CAUnavailableReason(CALiteSSL); reason != "" {
|
||||
t.Fatalf("LiteSSL 是可用的 CA,不该有停用原因:%q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// LiteSSL 支持通配符与多域名,因此 ValidateCADomains 不应拦它。
|
||||
func TestValidateCADomainsLiteSSLAllowsWildcard(t *testing.T) {
|
||||
domains := []string{"*.36800.cc", "a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}
|
||||
if err := ValidateCADomains(CALiteSSL, domains); err != nil {
|
||||
t.Fatalf("LiteSSL 应允许通配符与多域名:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Buypass 保留在停用表里(历史记录要能展示),但提示里应给出可用的替代。
|
||||
func TestBuypassIsRetiredWithAlternatives(t *testing.T) {
|
||||
reason := CAUnavailableReason(CABuypass)
|
||||
if reason == "" {
|
||||
t.Fatal("Buypass 应仍被标记为已停用")
|
||||
}
|
||||
if !strings.Contains(reason, "LiteSSL") {
|
||||
t.Fatalf("停用提示里应包含 LiteSSL 这个替代项:%q", reason)
|
||||
}
|
||||
// 旧记录仍能正常取到目录地址(不报「不支持的颁发机构」)
|
||||
if _, err := DirectoryURL(CABuypass); err != nil {
|
||||
t.Fatalf("历史 Buypass 记录的目录地址仍应可解析:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package acme
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseResolverList(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{"带端口", "223.5.5.5:53, 1.1.1.1:53", []string{"223.5.5.5:53", "1.1.1.1:53"}},
|
||||
{"缺端口补 53", "8.8.8.8\n9.9.9.9", []string{"8.8.8.8:53", "9.9.9.9:53"}},
|
||||
{"分号与空格", "223.5.5.5;8.8.4.4 1.0.0.1", []string{"223.5.5.5:53", "8.8.4.4:53", "1.0.0.1:53"}},
|
||||
{"空值", " ", nil},
|
||||
{"只留空白项", " , ; \n", nil},
|
||||
}
|
||||
for _, item := range cases {
|
||||
got := parseResolverList(item.raw)
|
||||
if len(got) != len(item.want) {
|
||||
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != item.want[i] {
|
||||
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolverListCapsEntries(t *testing.T) {
|
||||
raw := "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4,1.1.1.5,1.1.1.6,1.1.1.7,1.1.1.8,1.1.1.9,1.1.1.10"
|
||||
if got := parseResolverList(raw); len(got) != 8 {
|
||||
t.Fatalf("最多取前 8 条,实际 %d:%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 内置的默认值必须是 lego 能直接用的 host:port 形式
|
||||
func TestDefaultResolversAreUsable(t *testing.T) {
|
||||
got := parseResolverList(defaultACMEDNSResolvers)
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("默认解析器解析为空:%q", defaultACMEDNSResolvers)
|
||||
}
|
||||
if got[0] != "223.5.5.5:53" {
|
||||
t.Fatalf("默认解析器首项应带端口,实际 %q", got[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
legocert "github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
store *certstore.Store
|
||||
ddnsSvc *ddns.Service
|
||||
settings *settings.Store
|
||||
proxySvc *service.ProxyService
|
||||
logger *slog.Logger
|
||||
notify *notify.Service
|
||||
jobs *JobManager
|
||||
|
||||
expiryAlertMu sync.Mutex
|
||||
expiryAlerted map[string]string
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
ddnsSvc: ddnsSvc,
|
||||
settings: settings,
|
||||
proxySvc: proxySvc,
|
||||
logger: logger.With("module", "ACME"),
|
||||
notify: notifySvc,
|
||||
jobs: NewJobManager(),
|
||||
expiryAlerted: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
|
||||
if ddnsConfigID <= 0 {
|
||||
return "", fmt.Errorf("请选择 DNS 任务")
|
||||
}
|
||||
domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
job := s.jobs.Create()
|
||||
go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
|
||||
return job.ID(), nil
|
||||
}
|
||||
|
||||
func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
|
||||
return s.jobs.Stream(ctx, w, jobID, flush)
|
||||
}
|
||||
|
||||
func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
|
||||
domains, err := NormalizeCertDomains(domains)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
primary := PrimaryCertDomain(domains)
|
||||
if primary == "" {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
email, err = s.resolveACMEEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := ValidateCADomains(ca, domains); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
|
||||
cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := validateDNSCredentials(provider, cred); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
dnsZone := cfg.RootDomain
|
||||
zones := cfg.ManagedDNSZones()
|
||||
if len(zones) > 0 {
|
||||
dnsZone = zones[0]
|
||||
}
|
||||
return domains, ca, email, primary, dnsZone, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
|
||||
domains, err := NormalizeCertDomains(domains)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
|
||||
ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
primary := PrimaryCertDomain(domains)
|
||||
if primary == "" {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
|
||||
}
|
||||
email, err = s.resolveACMEEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
if err := ValidateCADomains(ca, domains); err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return nil, "", "", "", "", "", ddns.Credentials{}, err
|
||||
}
|
||||
return domains, ca, email, primary, dnsZone, provider, cred, nil
|
||||
}
|
||||
|
||||
func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
|
||||
ctx := context.Background()
|
||||
job.Info("开始申请证书…")
|
||||
job.Info(fmt.Sprintf("主域名: %s", primary))
|
||||
job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
|
||||
job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
|
||||
|
||||
rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
|
||||
if err != nil {
|
||||
s.markCertError(ctx, primary, domains, ca, err)
|
||||
job.Error(err.Error())
|
||||
job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
|
||||
return
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
|
||||
job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
job.Warn("Nginx 重载失败: " + err.Error())
|
||||
} else {
|
||||
job.Info("Nginx 已重载")
|
||||
}
|
||||
job.Info("证书申请完成")
|
||||
job.Finish(jobDoneFromRecord(rec, nil))
|
||||
}
|
||||
|
||||
func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
|
||||
domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
|
||||
if err != nil {
|
||||
s.markCertError(ctx, primary, domains, ca, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
|
||||
s.logger.Warn("save acme ca setting failed", "error", err.Error())
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
|
||||
}
|
||||
_ = rec
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
|
||||
certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("证书:%w", err)
|
||||
}
|
||||
keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("私钥:%w", err)
|
||||
}
|
||||
|
||||
info, err := certstore.InspectImport(certBytes, keyBytes)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if time.Now().After(info.ExpiresAt) {
|
||||
status = "expired"
|
||||
}
|
||||
|
||||
domainsJSON := EncodeCertDomains(info.Domains)
|
||||
record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
|
||||
}
|
||||
s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, domain string) error {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("域名不能为空")
|
||||
}
|
||||
existing, err := s.store.GetByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
|
||||
s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
|
||||
}
|
||||
if err := s.store.Delete(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
|
||||
}
|
||||
s.logger.Info("certificate deleted", "domain", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
|
||||
rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
|
||||
if rootDomain == "" {
|
||||
return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
|
||||
}
|
||||
existing, err := s.store.GetByDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if existing.ACMECA == "imported" {
|
||||
return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
|
||||
}
|
||||
if ca == "" {
|
||||
ca = existing.ACMECA
|
||||
}
|
||||
ca, err = s.resolveCA(ctx, ca)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
email, err := s.resolveACMEEmail(ctx, "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
domains := existing.Domains
|
||||
if len(domains) == 0 {
|
||||
domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
|
||||
}
|
||||
dnsZone, err := s.resolveDNSZone(ctx, domains)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
|
||||
s.markCertError(ctx, rootDomain, domains, ca, err)
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if err := s.proxySvc.ReloadAll(ctx); err != nil {
|
||||
s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
|
||||
}
|
||||
rec, err := s.store.GetByDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
if s.notify != nil {
|
||||
expires := "未知"
|
||||
if rec.ExpiresAt != nil {
|
||||
expires = rec.ExpiresAt.Local().Format("2006-01-02")
|
||||
}
|
||||
s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
|
||||
fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
records, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
|
||||
if err != nil || threshold <= 0 {
|
||||
threshold = 30
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.ExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
|
||||
s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
|
||||
}
|
||||
if rec.DaysLeft <= threshold {
|
||||
if rec.ACMECA == "imported" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
|
||||
s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
|
||||
if s.notify != nil {
|
||||
s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
|
||||
fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
|
||||
if s.notify == nil {
|
||||
return
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
s.expiryAlertMu.Lock()
|
||||
if s.expiryAlerted[domain] == today {
|
||||
s.expiryAlertMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.expiryAlerted[domain] = today
|
||||
s.expiryAlertMu.Unlock()
|
||||
|
||||
msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
|
||||
if daysLeft <= 0 {
|
||||
msg = fmt.Sprintf("%s 已到期或即将失效", domain)
|
||||
}
|
||||
s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
|
||||
}
|
||||
|
||||
func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
|
||||
_, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
|
||||
if upsertErr != nil {
|
||||
s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
|
||||
if strings.TrimSpace(ca) == "" {
|
||||
ca, err := s.settings.Get(ctx, settings.KeyACMECA)
|
||||
if err != nil || strings.TrimSpace(ca) == "" {
|
||||
ca = CALetsEncrypt
|
||||
}
|
||||
}
|
||||
ca = NormalizeCA(ca)
|
||||
if err := ValidateCA(ca); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := CAUnavailableReason(ca); reason != "" {
|
||||
return "", fmt.Errorf("%s", reason)
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
|
||||
reg, err := client.Registration.ResolveAccountByKey()
|
||||
if err == nil {
|
||||
return reg, nil
|
||||
}
|
||||
logStep("注册 ACME 账户…")
|
||||
switch NormalizeCA(ca) {
|
||||
case CAZeroSSL:
|
||||
apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||
TermsOfServiceAgreed: true,
|
||||
Kid: kid,
|
||||
HmacEncoded: hmac,
|
||||
})
|
||||
case CALiteSSL:
|
||||
// LiteSSL 的目录里 meta.externalAccountRequired = true:必须带 EAB。
|
||||
// 与 ZeroSSL 不同,不经过厂商 API 换取,而是用户在 FreeSSL 平台自建 EAB 后直接填 kid/hmac。
|
||||
kid, err := s.settings.Get(ctx, settings.KeyLiteSSLEABKid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hmac, err := s.settings.Get(ctx, settings.KeyLiteSSLEABHMAC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid = strings.TrimSpace(kid)
|
||||
hmac = strings.TrimSpace(hmac)
|
||||
if kid == "" || hmac == "" {
|
||||
return nil, fmt.Errorf("LiteSSL 要求 EAB 凭据:请在设置里填写 EAB Kid 与 EAB HMAC(在 FreeSSL 的「证书自动化 → EAB 管理」创建后原样粘贴)")
|
||||
}
|
||||
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||
TermsOfServiceAgreed: true,
|
||||
Kid: kid,
|
||||
HmacEncoded: hmac,
|
||||
})
|
||||
default:
|
||||
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
|
||||
logStep := func(msg string) {
|
||||
if job != nil {
|
||||
job.Info(msg)
|
||||
}
|
||||
}
|
||||
caDir, err := DirectoryURL(ca)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
|
||||
user, err := newUser(email)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
config := lego.NewConfig(user)
|
||||
config.CADirURL = caDir
|
||||
config.Certificate.KeyType = certcrypto.EC256
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
dnsProvider, err := newDNS01Provider(provider, cred)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
|
||||
}
|
||||
dnsProvider = wrapDNSProvider(dnsProvider, job)
|
||||
// DNS-01 的传播检查由 lego 自己做,它默认读系统 /etc/resolv.conf;
|
||||
// 容器里那通常是 Docker 的 127.0.0.11,它不返回权威 NS,于是报
|
||||
// 「[zone=example.com.] could not determine authoritative nameservers」。
|
||||
// 所以显式指定可靠递归解析器(设置项可改;设为 system 则显式回到系统解析器)。
|
||||
resolvers, err := s.acmeDNSResolvers(ctx)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
var challengeOpts []dns01.ChallengeOption
|
||||
if len(resolvers) > 0 {
|
||||
challengeOpts = append(challengeOpts, dns01.AddRecursiveNameservers(resolvers))
|
||||
}
|
||||
if err := client.Challenge.SetDNS01Provider(dnsProvider, challengeOpts...); err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
|
||||
logStep("检查 ACME 账户…")
|
||||
reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
|
||||
}
|
||||
user.registration = reg
|
||||
|
||||
logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
|
||||
request := legocert.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
}
|
||||
cert, err := client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
|
||||
}
|
||||
|
||||
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
logStep(fmt.Sprintf("证书已保存: %s", certPath))
|
||||
logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
|
||||
|
||||
expiresAt, err := parseCertExpiry(cert.Certificate)
|
||||
if err != nil {
|
||||
expiresAt = time.Now().Add(90 * 24 * time.Hour)
|
||||
}
|
||||
logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
|
||||
rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
|
||||
if err != nil {
|
||||
return certstore.Record{}, err
|
||||
}
|
||||
s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
|
||||
if err != nil || strings.TrimSpace(stored) == "" {
|
||||
return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
|
||||
}
|
||||
email = strings.TrimSpace(stored)
|
||||
}
|
||||
if err := validateACMEEmail(email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
|
||||
s.logger.Warn("save acme email failed", "error", err.Error())
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// defaultACMEDNSResolvers 默认递归解析器(阿里/Cloudflare/Google)。
|
||||
// 容器里的系统解析器常只指向 127.0.0.11,无法用于 DNS-01 的权威 NS 查询;
|
||||
// 设置项写 system 则回到系统解析器,写其它值则按逗号/空格分隔逐条使用。
|
||||
const defaultACMEDNSResolvers = "223.5.5.5:53,1.1.1.1:53,8.8.8.8:53"
|
||||
|
||||
// acmeDNSResolvers 返回本次申请要用的解析器;空 slice 表示交给 lego 读系统解析器。
|
||||
func (s *Service) acmeDNSResolvers(ctx context.Context) ([]string, error) {
|
||||
raw, err := s.settings.Get(ctx, settings.KeyACMEDNSResolvers)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
raw = defaultACMEDNSResolvers
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(raw), "system") {
|
||||
return nil, nil
|
||||
}
|
||||
resolvers := parseResolverList(raw)
|
||||
if len(resolvers) == 0 {
|
||||
return nil, fmt.Errorf("DNS 解析器配置无效(%q):请填 host 或 host:port,多个用逗号分隔;留空用默认值,填 system 用系统解析器", raw)
|
||||
}
|
||||
return resolvers, nil
|
||||
}
|
||||
|
||||
// parseResolverList 解析解析器列表:逗号/空格/换行分隔,未写端口时补 53,最多取前 8 个。
|
||||
func parseResolverList(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, item := range fields {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(item, ":") {
|
||||
item += ":53"
|
||||
}
|
||||
out = append(out, item)
|
||||
if len(out) >= 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateACMEEmail(email string) error {
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email {
|
||||
return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
|
||||
for _, domain := range domains {
|
||||
cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
|
||||
if err == nil {
|
||||
return cfg.RootDomain, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
|
||||
}
|
||||
|
||||
func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
|
||||
provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
|
||||
if err != nil {
|
||||
return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
|
||||
}
|
||||
if err := validateDNSCredentials(provider, cred); err != nil {
|
||||
return "", ddns.Credentials{}, err
|
||||
}
|
||||
return provider, cred, nil
|
||||
}
|
||||
|
||||
func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
|
||||
names := certStorageNames(domains)
|
||||
if len(names) == 0 {
|
||||
return "", "", fmt.Errorf("证书域名不能为空")
|
||||
}
|
||||
var certPath, keyPath string
|
||||
for _, name := range names {
|
||||
dir := filepath.Join(certsDir, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
cp := filepath.Join(dir, "fullchain.pem")
|
||||
kp := filepath.Join(dir, "privatekey.pem")
|
||||
if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath, keyPath = cp, kp
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
func removeCertFiles(certsDir string, record certstore.Record) error {
|
||||
domains := record.Domains
|
||||
if len(domains) == 0 {
|
||||
domains = LegacyCertDomains(record.Domain, record.Wildcard)
|
||||
}
|
||||
var firstErr error
|
||||
for _, name := range certStorageNames(domains) {
|
||||
dir := filepath.Join(certsDir, name)
|
||||
if err := os.RemoveAll(dir); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func certStorageNames(domains []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var names []string
|
||||
for _, domain := range domains {
|
||||
name := domain
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
name = "wildcard." + strings.TrimPrefix(domain, "*.")
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func parseCertExpiry(pemBytes []byte) (time.Time, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return time.Time{}, fmt.Errorf("invalid pem")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return cert.NotAfter, nil
|
||||
}
|
||||
|
||||
type acmeUser struct {
|
||||
email string
|
||||
registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *acmeUser) GetEmail() string { return u.email }
|
||||
func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
|
||||
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func newUser(email string) (*acmeUser, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &acmeUser{email: email, key: privateKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
type volcengineDNS struct {
|
||||
provider *ddns.Volcengine
|
||||
cred ddns.Credentials
|
||||
}
|
||||
|
||||
func newVolcengineDNSProvider(cred ddns.Credentials) (challenge.Provider, error) {
|
||||
if err := cred.Validate("volcengine"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &volcengineDNS{
|
||||
provider: ddns.NewVolcengine(),
|
||||
cred: cred,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *volcengineDNS) Present(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
|
||||
return p.provider.UpsertTXTRecord(context.Background(), p.cred, fqdn, info.Value)
|
||||
}
|
||||
|
||||
func (p *volcengineDNS) CleanUp(domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
|
||||
return p.provider.DeleteTXTRecord(context.Background(), p.cred, fqdn, info.Value)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type zeroSSLEABResponse struct {
|
||||
Success int `json:"success"`
|
||||
EABKid string `json:"eab_kid"`
|
||||
EABHmacKey string `json:"eab_hmac_key"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// zeroSSLEABEndpoint 单独成变量,测试可以替换成本地 httptest 服务。
|
||||
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials"
|
||||
|
||||
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
|
||||
}
|
||||
|
||||
endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL,
|
||||
// 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。
|
||||
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var payload zeroSSLEABResponse
|
||||
parseErr := json.Unmarshal(body, &payload)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 422 基本都是 access_key 无效(也可能是账号/权限问题)。ZeroSSL 会在
|
||||
// error.type 与 error.code 里写清原因,所以带上它,而不是只回状态码让用户猜。
|
||||
if parseErr == nil && payload.Error.Type != "" {
|
||||
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s(错误码 %d)", resp.StatusCode, payload.Error.Type, payload.Error.Code)
|
||||
}
|
||||
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s", resp.StatusCode, snippet(body))
|
||||
}
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", parseErr)
|
||||
}
|
||||
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
|
||||
if payload.Error.Type != "" {
|
||||
return "", "", fmt.Errorf("ZeroSSL API 错误:%s(错误码 %d)", payload.Error.Type, payload.Error.Code)
|
||||
}
|
||||
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
|
||||
}
|
||||
return payload.EABKid, payload.EABHmacKey, nil
|
||||
}
|
||||
|
||||
// redactURLError 只保留底层错误原因,避免把带 access_key 的 URL 写进日志。
|
||||
func redactURLError(err error) string {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
return urlErr.Err.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// snippet 压平并截断响应正文:既不把整页 HTML 灌进日志,也不让换行破坏排版。
|
||||
func snippet(body []byte) string {
|
||||
text := strings.Join(strings.Fields(string(body)), " ")
|
||||
if text == "" {
|
||||
return "(响应体为空)"
|
||||
}
|
||||
if len(text) > 200 {
|
||||
text = text[:200] + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withEndpoint 把 ZeroSSL 端点指向本地测试服务(用完恢复)。
|
||||
func withEndpoint(t *testing.T, handler http.HandlerFunc) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
previous := zeroSSLEABEndpoint
|
||||
zeroSSLEABEndpoint = server.URL
|
||||
t.Cleanup(func() {
|
||||
zeroSSLEABEndpoint = previous
|
||||
server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestZeroSSLEABCredentialsSuccess(t *testing.T) {
|
||||
withEndpoint(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("access_key"); got != "good-key" {
|
||||
t.Fatalf("access_key 应原样传出,实际 %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":1,"eab_kid":"kid-1","eab_hmac_key":"hmac-1"}`))
|
||||
})
|
||||
|
||||
kid, hmac, err := zerosslEABCredentials(context.Background(), "good-key")
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错:%v", err)
|
||||
}
|
||||
if kid != "kid-1" || hmac != "hmac-1" {
|
||||
t.Fatalf("凭据解析错误:kid=%q hmac=%q", kid, hmac)
|
||||
}
|
||||
}
|
||||
|
||||
// 422 时要把 ZeroSSL 给的错误类型带出来,而不是只回一个状态码让用户猜
|
||||
func TestZeroSSLEABCredentialsSurfacesErrorType(t *testing.T) {
|
||||
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":{"code":2902,"type":"invalid_api_key"}}`))
|
||||
})
|
||||
|
||||
_, _, err := zerosslEABCredentials(context.Background(), "bad-key")
|
||||
if err == nil {
|
||||
t.Fatal("期望报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "invalid_api_key") {
|
||||
t.Fatalf("错误里应含状态码与错误类型,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 JSON 的失败响应:退回截断正文,便于定位(例如网关返回的 HTML)
|
||||
func TestZeroSSLEABCredentialsFallsBackToBodySnippet(t *testing.T) {
|
||||
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("<html><body>\n 502 Bad Gateway\n</body></html>"))
|
||||
})
|
||||
|
||||
_, _, err := zerosslEABCredentials(context.Background(), "any")
|
||||
if err == nil || !strings.Contains(err.Error(), "502 Bad Gateway") {
|
||||
t.Fatalf("应带上响应正文摘要,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 网络错误必须脱敏:url.Error 的字符串里带 access_key,泄漏进日志等于泄密
|
||||
func TestZeroSSLEABCredentialsDoesNotLeakKey(t *testing.T) {
|
||||
const key = "SUPERSECRETACCESSKEY"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close() // 立刻关掉,制造连接失败
|
||||
|
||||
previous := zeroSSLEABEndpoint
|
||||
zeroSSLEABEndpoint = server.URL
|
||||
t.Cleanup(func() { zeroSSLEABEndpoint = previous })
|
||||
|
||||
_, _, err := zerosslEABCredentials(context.Background(), key)
|
||||
if err == nil {
|
||||
t.Fatal("期望请求失败")
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("错误信息里泄露了 API Key:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user