Skip to content

Commit 86820c8

Browse files
committed
feat(acme): 接入 HTTP-01 验证任务
1 parent 1c79347 commit 86820c8

9 files changed

Lines changed: 681 additions & 85 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/https-cert/deploy
33
go 1.26
44

55
require (
6+
connectrpc.com/connect v1.18.1
67
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.3
78
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
89
github.com/coder/websocket v1.8.15

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2+
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
3+
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
24
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
35
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
46
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=

internal/client/ws_client.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import (
1313
"google.golang.org/protobuf/encoding/protojson"
1414
)
1515

16+
// httpChallengeServer 描述 WebSocket challenge 处理依赖的本地 HTTP 服务能力。
17+
type httpChallengeServer interface {
18+
// SetChallenge 缓存一条可由 HTTP 端点响应的 challenge。
19+
SetChallenge(token, response, domain string) error
20+
// RemoveChallenge 精确删除指定 token。
21+
RemoveChallenge(token string) error
22+
}
23+
1624
type WSClient struct {
1725
clientId string
1826
serverURL string
@@ -22,7 +30,7 @@ type WSClient struct {
2230
lastDisconnectLogged atomic.Bool
2331
systemInfo *system.SystemInfo
2432
systemInfoOnce sync.Once
25-
httpServer *server.HTTPServer
33+
httpServer httpChallengeServer
2634
busyOperations atomic.Int32
2735
conn *websocket.Conn
2836
connMu sync.Mutex

internal/client/ws_message_handler.go

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,10 @@ func (c *WSClient) handleMessage(resp *deployPB.NotifyResponse) {
100100
}
101101

102102
case deployPB.Type_CHALLENGE:
103-
if businesResp, ok := resp.Data.(*deployPB.NotifyResponse_ExecuteBusinesResponse); ok {
104-
go c.handleChallenge(businesResp.ExecuteBusinesResponse)
103+
if challengeReq, ok := resp.Data.(*deployPB.NotifyResponse_ChallengeRequest); ok {
104+
go c.handleChallenge(resp.RequestId, challengeReq.ChallengeRequest)
105+
} else if businesResp, ok := resp.Data.(*deployPB.NotifyResponse_ExecuteBusinesResponse); ok {
106+
go c.handleLegacyChallenge(businesResp.ExecuteBusinesResponse)
105107
}
106108

107109
case deployPB.Type_EXECUTE_BUSINES:
@@ -165,8 +167,46 @@ func (c *WSClient) handleUpdate() {
165167
updateHandler.HandleUpdate()
166168
}
167169

168-
// handleChallenge 处理 ACME HTTP-01 challenge
169-
func (c *WSClient) handleChallenge(resp *deployPB.ExecuteBusinesResponse) {
170+
// handleChallenge 执行 HTTP-01 challenge 请求并返回同 requestId 的 ACK。
171+
func (c *WSClient) handleChallenge(requestID string, request *deployPB.ChallengeRequest) {
172+
result, message := c.executeChallengeRequest(request)
173+
c.sendChallengeResponse(requestID, request, result, message)
174+
}
175+
176+
// executeChallengeRequest 校验并执行一条设置或删除 challenge 的请求。
177+
func (c *WSClient) executeChallengeRequest(request *deployPB.ChallengeRequest) (deployPB.ChallengeResponse_Result, string) {
178+
if request == nil {
179+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_FAILED, "challenge 请求为空"
180+
}
181+
if request.OperationId <= 0 || request.CertId <= 0 || request.Domain == "" || request.Token == "" {
182+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_FAILED, "challenge 请求参数不完整"
183+
}
184+
if c.httpServer == nil {
185+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_FAILED, "HTTP-01 服务未初始化"
186+
}
187+
188+
switch request.Action {
189+
case deployPB.ChallengeRequest_CHALLENGE_ACTION_SET:
190+
if err := c.httpServer.SetChallenge(request.Token, request.KeyAuth, request.Domain); err != nil {
191+
logger.Error("设置 Challenge 失败", "error", err, "operationId", request.OperationId, "certId", request.CertId, "domain", request.Domain)
192+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_FAILED, err.Error()
193+
}
194+
logger.Info("设置 Challenge", "operationId", request.OperationId, "certId", request.CertId, "token", request.Token, "domain", request.Domain)
195+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_SUCCESS, "challenge 已缓存"
196+
case deployPB.ChallengeRequest_CHALLENGE_ACTION_DELETE:
197+
if err := c.httpServer.RemoveChallenge(request.Token); err != nil {
198+
logger.Error("删除 Challenge 失败", "error", err, "operationId", request.OperationId, "certId", request.CertId, "domain", request.Domain)
199+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_FAILED, err.Error()
200+
}
201+
logger.Info("删除 Challenge", "operationId", request.OperationId, "certId", request.CertId, "token", request.Token, "domain", request.Domain)
202+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_SUCCESS, "challenge 已删除"
203+
default:
204+
return deployPB.ChallengeResponse_CHALLENGE_RESULT_NOT_SUPPORTED, "不支持的 challenge 操作"
205+
}
206+
}
207+
208+
// handleLegacyChallenge 兼容处理旧服务端复用业务消息发送的 challenge。
209+
func (c *WSClient) handleLegacyChallenge(resp *deployPB.ExecuteBusinesResponse) {
170210
token := resp.ChallengeToken
171211
challengeResp := resp.ChallengeResponse
172212
domain := resp.Domain
@@ -183,13 +223,19 @@ func (c *WSClient) handleChallenge(resp *deployPB.ExecuteBusinesResponse) {
183223

184224
// 如果 challengeResp 为空,表示后端要求删除此 challenge(过期/取消)
185225
if challengeResp == "" {
186-
c.httpServer.RemoveChallenge(token)
226+
if err := c.httpServer.RemoveChallenge(token); err != nil {
227+
logger.Error("删除旧版 Challenge 失败", "error", err, "token", token, "domain", domain)
228+
return
229+
}
187230
logger.Info("删除Challenge", "token", token, "domain", domain)
188231
return
189232
}
190233

191234
// 正常情况:缓存新的 challenge
192-
c.httpServer.SetChallenge(token, challengeResp, domain)
235+
if err := c.httpServer.SetChallenge(token, challengeResp, domain); err != nil {
236+
logger.Error("设置旧版 Challenge 失败", "error", err, "token", token, "domain", domain)
237+
return
238+
}
193239
logger.Info("设置Challenge", "token", token, "domain", domain)
194240
}
195241

internal/client/ws_sender.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,30 @@ func (c *WSClient) sendExecuteBusinesResponse(requestId string, result deployPB.
177177
logger.Error("发送执行业务响应失败", "error", err, "requestId", requestId)
178178
}
179179
}
180+
181+
// sendChallengeResponse 回传 HTTP-01 challenge 设置或删除结果。
182+
func (c *WSClient) sendChallengeResponse(requestID string, request *deployPB.ChallengeRequest, result deployPB.ChallengeResponse_Result, resultMessage string) {
183+
if request == nil || requestID == "" {
184+
logger.Error("无法发送 Challenge ACK", "requestId", requestID, "message", resultMessage)
185+
return
186+
}
187+
req := &deployPB.NotifyRequest{
188+
AccessKey: c.accessKey,
189+
ClientId: c.clientId,
190+
Version: config.Version,
191+
RequestId: requestID,
192+
Data: &deployPB.NotifyRequest_ChallengeResponse{
193+
ChallengeResponse: &deployPB.ChallengeResponse{
194+
OperationId: request.OperationId,
195+
CertId: request.CertId,
196+
Domain: request.Domain,
197+
Token: request.Token,
198+
Result: result,
199+
Message: resultMessage,
200+
},
201+
},
202+
}
203+
if err := c.sendNotifyRequest(req); err != nil {
204+
logger.Error("发送 Challenge ACK 失败", "error", err, "requestId", requestID, "operationId", request.OperationId)
205+
}
206+
}

internal/scheduler/scheduler.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ func NewScheduler(ctx context.Context) (*Scheduler, error) {
3636
AccessKey: client.GetAccessKey(),
3737
})
3838

39-
// 启动WebSocket客户端连接
40-
client.Start()
41-
4239
// 创建 HTTP-01 验证服务器
4340
httpServer := server.NewHTTPServer()
4441

@@ -68,6 +65,36 @@ func Start(ctx context.Context) {
6865
}
6966
}()
7067

68+
// HTTP 服务启动后再连接平台,避免刚上线就收到 challenge 却无法缓存。
69+
readyDeadline := time.NewTimer(5 * time.Second)
70+
readyTicker := time.NewTicker(20 * time.Millisecond)
71+
waiting := true
72+
for waiting {
73+
select {
74+
case <-ctx.Done():
75+
readyTicker.Stop()
76+
readyDeadline.Stop()
77+
return
78+
case <-readyDeadline.C:
79+
logger.Error("HTTP-01 验证服务未能在限定时间内启动")
80+
waiting = false
81+
case <-readyTicker.C:
82+
if scheduler.httpServer.IsReady() {
83+
waiting = false
84+
}
85+
}
86+
}
87+
readyTicker.Stop()
88+
if !readyDeadline.Stop() {
89+
select {
90+
case <-readyDeadline.C:
91+
default:
92+
}
93+
}
94+
95+
// 即使 HTTP 服务启动失败也保持平台连接,challenge 请求会收到明确失败 ACK。
96+
scheduler.client.Start()
97+
7198
// 等待上下文取消
7299
<-ctx.Done()
73100

internal/server/http_server.go

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,25 @@ package server
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
8+
"net"
79
"net/http"
810
"sort"
911
"strings"
1012
"sync"
13+
"sync/atomic"
1114
"time"
1215

1316
"github.com/https-cert/deploy/internal/config"
1417
"github.com/https-cert/deploy/pkg/logger"
1518
)
1619

20+
var (
21+
// ErrHTTPServerNotReady 表示本地 HTTP-01 服务尚未成功监听端口。
22+
ErrHTTPServerNotReady = errors.New("HTTP-01 服务尚未启动")
23+
)
24+
1725
// ChallengeCache 存储 ACME challenge token 和 response 的映射
1826
type ChallengeCache struct {
1927
// mu 保护 challenge 缓存的并发读写。
@@ -36,6 +44,8 @@ type HTTPServer struct {
3644
server *http.Server
3745
// cache 保存当前等待验证的 HTTP-01 challenge。
3846
cache *ChallengeCache
47+
// ready 表示 HTTP 服务已经成功监听配置端口。
48+
ready atomic.Bool
3949
}
4050

4151
type healthResponse struct {
@@ -104,13 +114,25 @@ func newChallengeCache() *ChallengeCache {
104114

105115
// Start 启动 HTTP 服务器
106116
func (s *HTTPServer) Start() error {
107-
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
117+
listener, err := net.Listen("tcp", s.server.Addr)
118+
if err != nil {
119+
return fmt.Errorf("HTTP 服务器监听失败: %w", err)
120+
}
121+
s.ready.Store(true)
122+
defer s.ready.Store(false)
123+
124+
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
108125
return fmt.Errorf("HTTP 服务器启动失败: %w", err)
109126
}
110127

111128
return nil
112129
}
113130

131+
// IsReady 判断 HTTP-01 服务是否已经成功监听端口。
132+
func (s *HTTPServer) IsReady() bool {
133+
return s != nil && s.ready.Load()
134+
}
135+
114136
// Stop 停止 HTTP 服务器
115137
func (s *HTTPServer) Stop(ctx context.Context) error {
116138
logger.Info("正在停止 HTTP-01 验证服务")
@@ -178,14 +200,28 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
178200
}
179201
}
180202

181-
// SetChallenge 设置 challenge token 和 response,10 分钟后过期
182-
func (s *HTTPServer) SetChallenge(token, response, domain string) {
203+
// SetChallenge 设置 challenge token 和 response,10 分钟后过期。
204+
func (s *HTTPServer) SetChallenge(token, response, domain string) error {
205+
if !s.IsReady() {
206+
return ErrHTTPServerNotReady
207+
}
208+
if strings.TrimSpace(token) == "" || strings.TrimSpace(response) == "" {
209+
return errors.New("challenge token 或 key authorization 为空")
210+
}
183211
s.cache.Set(token, response, domain, time.Minute*10)
212+
return nil
184213
}
185214

186-
// RemoveChallenge 移除 challenge
187-
func (s *HTTPServer) RemoveChallenge(token string) {
215+
// RemoveChallenge 精确移除 challenge token。
216+
func (s *HTTPServer) RemoveChallenge(token string) error {
217+
if !s.IsReady() {
218+
return ErrHTTPServerNotReady
219+
}
220+
if strings.TrimSpace(token) == "" {
221+
return errors.New("challenge token 为空")
222+
}
188223
s.cache.Delete(token)
224+
return nil
189225
}
190226

191227
// cleanupExpiredChallenges 定期清理过期的 challenge

0 commit comments

Comments
 (0)