2026-09-23 09:40:59 +08:00
|
|
|
|
package acme
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
2026-09-27 10:10:15 +08:00
|
|
|
|
"errors"
|
2026-09-23 09:40:59 +08:00
|
|
|
|
"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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:10:15 +08:00
|
|
|
|
// zeroSSLEABEndpoint 单独成变量,测试可以替换成本地 httptest 服务。
|
|
|
|
|
|
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials"
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
|
|
|
|
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:10:15 +08:00
|
|
|
|
endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
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 {
|
2026-09-27 10:10:15 +08:00
|
|
|
|
// 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL,
|
|
|
|
|
|
// 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。
|
|
|
|
|
|
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err))
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
var payload zeroSSLEABResponse
|
2026-09-27 10:10:15 +08:00
|
|
|
|
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)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
|
|
|
|
|
|
if payload.Error.Type != "" {
|
2026-09-27 10:10:15 +08:00
|
|
|
|
return "", "", fmt.Errorf("ZeroSSL API 错误:%s(错误码 %d)", payload.Error.Type, payload.Error.Code)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
|
|
|
|
|
|
}
|
|
|
|
|
|
return payload.EABKid, payload.EABHmacKey, nil
|
|
|
|
|
|
}
|
2026-09-27 10:10:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|