feat(cert,scripts,docs): Linux 原生安装包与证书申请三处修复(DNS 解析器 / Buypass 下线 / ZeroSSL 报错与密钥脱敏)(v1.2.13)
CI / verify (push) Canceled after 0s
CI / verify (push) Canceled after 0s
- Linux 安装包:scripts/build-linux.ps1 交叉编译出 fonu-<版本>-linux-{amd64,arm64}.tar.gz(二进制 + migrations + systemd 安装脚本 + 运行时资源 + sha256),scripts/fonu-install.sh 负责装机(/opt/fonu、/var/lib/fonu、/etc/fonu/fonu.env 首次生成随机会话密钥)并新增 --reset-admin;装完直接打印初始管理员口令
- 证书 DNS-01:lego 默认读系统 resolv.conf(容器里是 Docker 的 127.0.0.11,不返回权威 NS)导致 could not determine authoritative nameservers;现显式传 AddRecursiveNameservers,默认 223.5.5.5/1.1.1.1/8.8.8.8,设置项 acme_dns_resolvers 可覆盖(system = 回到系统解析器)
- Buypass 已停止签发(2025-10-15)并终止服务(2026-04-15):从可选 CA 移除,历史记录仍可展示,直接提交会被拦下并给出中文原因
- ZeroSSL:422 时带出 error.type/error.code(非 JSON 退回截断正文);同时修掉网络错误把含 access_key 的完整 URL 写进任务日志与 app.log 的密钥泄露
- 测试:解析器列表解析 3 例 + ZeroSSL EAB 4 例(含不泄露密钥断言);gofmt / go build / go vet / go test 全绿
This commit is contained in:
+12
-1
@@ -14,6 +14,18 @@ const (
|
||||
CABuypass = "buypass"
|
||||
)
|
||||
|
||||
// caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理),
|
||||
// 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。
|
||||
// 否则用户只会看到一个难懂的 ACME 报错。
|
||||
var caRetired = map[string]string{
|
||||
CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt 或 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"
|
||||
@@ -86,4 +98,3 @@ func ValidateCADomains(ca string, domains []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"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"
|
||||
|
||||
@@ -47,12 +48,12 @@ type Service struct {
|
||||
|
||||
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"),
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
ddnsSvc: ddnsSvc,
|
||||
settings: settings,
|
||||
proxySvc: proxySvc,
|
||||
logger: logger.With("module", "ACME"),
|
||||
notify: notifySvc,
|
||||
jobs: NewJobManager(),
|
||||
expiryAlerted: map[string]string{},
|
||||
@@ -388,6 +389,9 @@ func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
|
||||
if err := ValidateCA(ca); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := CAUnavailableReason(ca); reason != "" {
|
||||
return "", fmt.Errorf("%s", reason)
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
@@ -446,7 +450,19 @@ func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider stri
|
||||
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
|
||||
}
|
||||
dnsProvider = wrapDNSProvider(dnsProvider, job)
|
||||
if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -505,6 +521,49 @@ func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, e
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package acme
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -21,13 +22,16 @@ type zeroSSLEABResponse struct {
|
||||
} `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 := "https://api.zerossl.com/acme/eab-credentials?access_key=" + url.QueryEscape(apiKey)
|
||||
endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -36,7 +40,9 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err)
|
||||
// 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL,
|
||||
// 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。
|
||||
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -44,19 +50,45 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("ZeroSSL API 返回 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload zeroSSLEABResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", err)
|
||||
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", 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)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, []map[string]string{
|
||||
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
|
||||
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
|
||||
{"value": acme.CABuypass, "label": acme.CALabel(acme.CABuypass)},
|
||||
// Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,
|
||||
// 不再列为可选 CA(历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下)。
|
||||
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyACMEEmail: true,
|
||||
settings.KeyACMECA: true,
|
||||
settings.KeyZeroSSLAPIKey: true,
|
||||
settings.KeyACMEDNSResolvers: true,
|
||||
settings.KeyNotifyWebhookURL: true,
|
||||
settings.KeyNotifyType: true,
|
||||
settings.KeyNotifyEmailJSON: true,
|
||||
|
||||
@@ -8,15 +8,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
KeyTheme = "theme"
|
||||
KeyTimezone = "timezone"
|
||||
KeyLogRetentionDays = "log_retention_days"
|
||||
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
|
||||
KeyCertRenewThreshold = "cert_renew_threshold_days"
|
||||
KeyRootDomain = "root_domain"
|
||||
KeyACMEEmail = "acme_email"
|
||||
KeyACMECA = "acme_ca"
|
||||
KeyZeroSSLAPIKey = "zerossl_api_key"
|
||||
KeyTheme = "theme"
|
||||
KeyTimezone = "timezone"
|
||||
KeyLogRetentionDays = "log_retention_days"
|
||||
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
|
||||
KeyCertRenewThreshold = "cert_renew_threshold_days"
|
||||
KeyRootDomain = "root_domain"
|
||||
KeyACMEEmail = "acme_email"
|
||||
KeyACMECA = "acme_ca"
|
||||
KeyZeroSSLAPIKey = "zerossl_api_key"
|
||||
// KeyACMEDNSResolvers DNS-01 传播检查用的递归解析器(逗号分隔;system = 用系统解析器)
|
||||
KeyACMEDNSResolvers = "acme_dns_resolvers"
|
||||
KeyNotifyWebhookURL = "notify_webhook_url"
|
||||
KeyNotifyType = "notify_type"
|
||||
KeyNotifyEmailJSON = "notify_email_json"
|
||||
|
||||
Reference in New Issue
Block a user