Phase 5 — ACME / Let's Encrypt 自动证书模块骨架
Some checks failed
CI / build (push) Failing after 30s

- 新增 acme.h / acme.c:ACME RFC 8555 协议客户端基础实现
  - JWS ES256 签名(账户密钥管理)
  - ACME 目录发现、Nonce 管理
  - 账户创建/复用(newAccount)
  - 订单创建(newOrder)
  - 授权获取与挑战响应(HTTP-01)
  - 订单轮询与证书下载
  - 一键签发封装 acme_issue_certificate()
- 新增 tests/unit/test_acme.c:9 项单元测试
- Makefile:添加 acme.c 编译、-lcurl 链接、test_acme 测试规则

当前状态:ACME 骨架完成,JWS 签名需后续完善以通过
Let's Encrypt 验证。下阶段完成 HTTP-01 挑战服务器集成。
This commit is contained in:
xfy911 2026-06-16 03:25:47 +08:00
parent c2a5deec9a
commit 4a6e7adc17
4 changed files with 1450 additions and 7 deletions

View File

@ -6,8 +6,8 @@ COCO_INCLUDE ?= $(COCO_DIR)/include
COCO_LIB ?= $(COCO_DIR)/build COCO_LIB ?= $(COCO_DIR)/build
CC = gcc CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_GNU_SOURCE -I$(COCO_INCLUDE) CFLAGS = -Wall -Wextra -O2 -std=c11 -D_GNU_SOURCE -I$(COCO_INCLUDE) -I/usr/include/x86_64-linux-gnu
LDFLAGS = -L$(COCO_LIB) -lcoco -lpthread -lm -luring -lz -lbrotlienc -lssl -lcrypto -lnghttp2 -ldl LDFLAGS = -L$(COCO_LIB) -lcoco -lpthread -lm -luring -lz -lbrotlienc -lssl -lcrypto -lnghttp2 -ldl -lcurl
# 调试模式 # 调试模式
DEBUG ?= 0 DEBUG ?= 0
@ -20,7 +20,7 @@ PREFIX ?= /usr/local
BINDIR = $(PREFIX)/bin BINDIR = $(PREFIX)/bin
# 源文件 # 源文件
SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c acme.c
OBJS = $(SRCS:.c=.o) OBJS = $(SRCS:.c=.o)
TARGET = cocoon TARGET = cocoon
@ -90,8 +90,8 @@ unit-test: $(UNIT_TEST_BINS)
fi fi
# 单元测试编译规则 # 单元测试编译规则
$(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c acme.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c $(UNITY_SRC) $(LDFLAGS) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c acme.c $(UNITY_SRC) $(LDFLAGS)
$(UNIT_TEST_DIR)/test_multipart: $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_multipart: $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) -lm $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) -lm
@ -99,8 +99,8 @@ $(UNIT_TEST_DIR)/test_multipart: $(UNIT_TEST_DIR)/test_multipart.c multipart.c $
$(UNIT_TEST_DIR)/test_http: $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_http: $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SRC) -lm $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SRC) -lm
$(UNIT_TEST_DIR)/test_static: $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c access_log.c platform.c cache.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_static: $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c access_log.c platform.c cache.c acme.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c access_log.c platform.c cache.c $(UNITY_SRC) $(LDFLAGS) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c access_log.c platform.c cache.c acme.c $(UNITY_SRC) $(LDFLAGS)
$(UNIT_TEST_DIR)/test_websocket: $(UNIT_TEST_DIR)/test_websocket.c websocket.c log.c platform.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_websocket: $(UNIT_TEST_DIR)/test_websocket.c websocket.c log.c platform.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_websocket.c websocket.c log.c platform.c $(UNITY_SRC) $(LDFLAGS) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_websocket.c websocket.c log.c platform.c $(UNITY_SRC) $(LDFLAGS)
@ -150,6 +150,10 @@ $(UNIT_TEST_DIR)/test_cache: $(UNIT_TEST_DIR)/test_cache.c cache.c log.c $(UNITY
$(UNIT_TEST_DIR)/test_dashboard: $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC) $(UNIT_TEST_DIR)/test_dashboard: $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC) $(LDFLAGS) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC) $(LDFLAGS)
# ACME 测试
$(UNIT_TEST_DIR)/test_acme: $(UNIT_TEST_DIR)/test_acme.c acme.c log.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_acme.c acme.c log.c $(UNITY_SRC) $(LDFLAGS)
# 安装 # 安装
install: $(TARGET) install: $(TARGET)
install -d $(BINDIR) install -d $(BINDIR)

1027
acme.c Normal file

File diff suppressed because it is too large Load Diff

258
acme.h Normal file
View File

@ -0,0 +1,258 @@
/**
* acme.h - ACME / Let's Encrypt
*
* RFC 8555 ACME HTTP-01
*
*
* :
* - ACME
* - JWS 使 OpenSSL EVP
* - HTTP-01
* -
*
* @author xfy
*/
#ifndef COCOON_ACME_H
#define COCOON_ACME_H
#include <stdbool.h>
#include <stddef.h>
#include <time.h>
/* ACME 目录端点结构 */
typedef struct {
char newNonce[512];
char newAccount[512];
char newOrder[512];
char revokeCert[512];
char keyChange[512];
} acme_directory_t;
/* ACME 账户信息 */
typedef struct {
char kid[512]; /* 账户 URL (Key ID) */
char contact[256]; /* 联系邮箱 */
bool termsAgreed; /* 是否同意服务条款 */
} acme_account_t;
/* ACME 授权 */
typedef struct {
char domain[256]; /* 授权域名 */
char status[32]; /* pending / valid / invalid / deactivated / expired / revoked */
char token[256]; /* HTTP-01 挑战 token */
char uri[512]; /* 授权 URI */
} acme_authz_t;
/* ACME 订单 */
typedef struct {
char uri[512]; /* 订单 URI */
char status[32]; /* pending / ready / processing / valid / invalid */
char finalize[512]; /* finalize 端点 */
char certificate[512]; /* 证书下载端点 */
time_t expires; /* 过期时间 */
acme_authz_t *authz; /* 授权数组 */
size_t num_authz; /* 授权数量 */
} acme_order_t;
/* ACME 客户端上下文 */
typedef struct acme_ctx acme_ctx_t;
/**
* acme_create - ACME
*
* @param directory_url ACME URL Let's Encrypt
* @param account_key_pem PEM EC P-256NULL
* @return ACME NULL
*/
acme_ctx_t *acme_create(const char *directory_url, const char *account_key_pem);
/**
* acme_destroy - ACME
*
* @param ctx ACME
*/
void acme_destroy(acme_ctx_t *ctx);
/**
* acme_get_directory - ACME
*
* directory_url GET JSON
*
* @param ctx ACME
* @param out
* @return 0 -1
*/
int acme_get_directory(acme_ctx_t *ctx, acme_directory_t *out);
/**
* acme_get_nonce - nonce
*
* ACME POST Replay-Nonce
*
* @param ctx ACME
* @param nonce_url newNonce URL
* @return 0 -1
*/
int acme_get_nonce(acme_ctx_t *ctx, const char *nonce_url);
/**
* acme_create_account - ACME
*
* 200
*
* @param ctx ACME
* @param email "admin@example.com"
* @param terms_agreed
* @return 0 -1
*/
int acme_create_account(acme_ctx_t *ctx, const char *email, bool terms_agreed);
/**
* acme_create_order -
*
* @param ctx ACME
* @param domains
* @param num_domains
* @param out out->authz
* @return 0 -1
*/
int acme_create_order(acme_ctx_t *ctx, const char **domains, size_t num_domains,
acme_order_t *out);
/**
* acme_order_free -
*
* @param order
*/
void acme_order_free(acme_order_t *order);
/**
* acme_fetch_authz -
*
* @param ctx ACME
* @param authz uri token
* @return 0 -1
*/
int acme_fetch_authz(acme_ctx_t *ctx, acme_authz_t *authz);
/**
* acme_respond_challenge - HTTP-01
*
* ACME
*
* @param ctx ACME
* @param authz
* @return 0 -1
*/
int acme_respond_challenge(acme_ctx_t *ctx, const acme_authz_t *authz);
/**
* acme_poll_authz -
*
* @param ctx ACME
* @param authz
* @param timeout_ms
* @return 0 valid-1
*/
int acme_poll_authz(acme_ctx_t *ctx, acme_authz_t *authz, int timeout_ms);
/**
* acme_finalize_order - CSR
*
* @param ctx ACME
* @param order
* @param domains
* @param num_domains
* @return 0 -1
*/
int acme_finalize_order(acme_ctx_t *ctx, acme_order_t *order,
const char **domains, size_t num_domains);
/**
* acme_poll_order -
*
* @param ctx ACME
* @param order
* @param timeout_ms
* @return 0 valid-1
*/
int acme_poll_order(acme_ctx_t *ctx, acme_order_t *order, int timeout_ms);
/**
* acme_download_certificate -
*
* @param ctx ACME
* @param cert_url URL
* @param out_pem PEM free
* @return 0 -1
*/
int acme_download_certificate(acme_ctx_t *ctx, const char *cert_url, char **out_pem);
/**
* acme_get_thumbprint - JWK Thumbprint
*
* HTTP-01 = token + "." + base64url(thumbprint)
*
* @param ctx ACME
* @param out thumbprintbase64url free
* @return 0 -1
*/
int acme_get_thumbprint(acme_ctx_t *ctx, char **out);
/**
* acme_issue_certificate -
*
*
*
*
* @param ctx ACME
* @param domains
* @param num_domains
* @param email
* @param cert_pem PEM free
* @param key_pem PEM free
* @return 0 -1
*/
int acme_issue_certificate(acme_ctx_t *ctx, const char **domains, size_t num_domains,
const char *email, char **cert_pem, char **key_pem);
/* HTTP 请求辅助(内部使用,暴露给单元测试) */
typedef struct {
int status;
char *body;
size_t body_len;
char location[512];
char replay_nonce[256];
} acme_http_response_t;
/**
* acme_http_post_jws - JWS POST
*
* @param ctx ACME
* @param url URL
* @param payload JSON payload NULL
* @param out
* @return 0 -1
*/
int acme_http_post_jws(acme_ctx_t *ctx, const char *url, const char *payload,
acme_http_response_t *out);
/**
* acme_http_get - GET
*
* @param ctx ACME
* @param url URL
* @param out
* @return 0 -1
*/
int acme_http_get(acme_ctx_t *ctx, const char *url, acme_http_response_t *out);
/**
* acme_http_response_free - HTTP
*
* @param resp
*/
void acme_http_response_free(acme_http_response_t *resp);
#endif /* COCOON_ACME_H */

154
tests/unit/test_acme.c Normal file
View File

@ -0,0 +1,154 @@
/**
* test_acme.c - ACME
*
* @author xfy
*/
#include "unity.h"
#include "acme.h"
#include "log.h"
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
/* 测试用的 EC 私钥 PEM (有效格式但随机数据) */
static const char *TEST_KEY_PEM =
"-----BEGIN EC PRIVATE KEY-----\n"
"MHQCAQEEIBHkPjHK+3VjZV+p7rKDcAEO2db7t4V1b01QfZVJHfHmoAcGBSuBBAAK\n"
"oUQDQgAEX0hOmL0zQ0y1P1uOHZPVjR1xBZQlpVZ1PjY2V0tZ0V0tZ0V0tZ0V0\n"
"tZ0V0tZ0V0tZ0V0tZ0V0tZ0V0tZw==\n"
"-----END EC PRIVATE KEY-----\n";
void setUp(void) {
log_set_level(LOG_LEVEL_ERROR);
}
void tearDown(void) {
}
/* 测试: acme_create / acme_destroy */
void test_acme_create_destroy(void) {
acme_ctx_t *ctx = acme_create("https://acme-staging-v02.api.letsencrypt.org/directory", NULL);
TEST_ASSERT_NOT_NULL(ctx);
acme_destroy(ctx);
}
/* 测试: acme_create 自带私钥 */
void test_acme_create_with_key(void) {
acme_ctx_t *ctx = acme_create("https://acme-staging-v02.api.letsencrypt.org/directory", TEST_KEY_PEM);
/* 这个 PEM 是伪造的,可能解析失败,但至少测试了代码路径 */
acme_destroy(ctx);
}
/* 测试: acme_get_directory (需要网络,可能失败) */
void test_acme_get_directory(void) {
acme_ctx_t *ctx = acme_create("https://acme-staging-v02.api.letsencrypt.org/directory", NULL);
TEST_ASSERT_NOT_NULL(ctx);
acme_directory_t dir;
int ret = acme_get_directory(ctx, &dir);
/* 网络可能不可用,允许失败 */
if (ret == 0) {
TEST_ASSERT_TRUE(dir.newNonce[0] != '\0');
TEST_ASSERT_TRUE(dir.newAccount[0] != '\0');
TEST_ASSERT_TRUE(dir.newOrder[0] != '\0');
}
acme_destroy(ctx);
}
/* 测试: acme_get_nonce (需要网络) */
void test_acme_get_nonce(void) {
acme_ctx_t *ctx = acme_create("https://acme-staging-v02.api.letsencrypt.org/directory", NULL);
TEST_ASSERT_NOT_NULL(ctx);
/* 先获取目录 */
acme_directory_t dir;
if (acme_get_directory(ctx, &dir) == 0) {
int ret = acme_get_nonce(ctx, dir.newNonce);
/* nonce 获取成功或失败都可以接受(网络问题) */
(void)ret;
}
acme_destroy(ctx);
}
/* 测试: acme_create_account (需要网络) */
void test_acme_create_account(void) {
acme_ctx_t *ctx = acme_create("https://acme-staging-v02.api.letsencrypt.org/directory", NULL);
TEST_ASSERT_NOT_NULL(ctx);
/* 获取目录 */
acme_directory_t dir;
if (acme_get_directory(ctx, &dir) == 0) {
int ret = acme_create_account(ctx, "test@example.com", true);
/* 可能 200(已有账户) 或 201(新账户),网络问题也可能失败 */
(void)ret;
}
acme_destroy(ctx);
}
/* 测试: 订单结构内存管理 */
void test_acme_order_free(void) {
acme_order_t order;
memset(&order, 0, sizeof(order));
/* 模拟有 authz */
order.authz = (acme_authz_t *)calloc(2, sizeof(acme_authz_t));
order.num_authz = 2;
strcpy(order.authz[0].domain, "example.com");
strcpy(order.authz[0].token, "token1");
strcpy(order.authz[1].domain, "www.example.com");
strcpy(order.authz[1].token, "token2");
acme_order_free(&order);
TEST_ASSERT_NULL(order.authz);
TEST_ASSERT_EQUAL_size_t(0, order.num_authz);
}
/* 测试: HTTP 响应内存释放 */
void test_acme_http_response_free(void) {
acme_http_response_t resp;
memset(&resp, 0, sizeof(resp));
resp.body = strdup("test body");
resp.body_len = 9;
acme_http_response_free(&resp);
TEST_ASSERT_NULL(resp.body);
TEST_ASSERT_EQUAL_size_t(0, resp.body_len);
}
/* 测试: acme_issue_certificate 参数检查 (不实际执行) */
void test_acme_issue_certificate_params(void) {
/* 这个测试主要是确保函数签名正确,不会崩溃 */
/* 实际调用需要完整的 ACME 交互,不适合单元测试 */
TEST_ASSERT_TRUE(1);
}
/* 测试: 目录 URL 存储 */
void test_acme_directory_url(void) {
const char *url = "https://example.com/acme/directory";
acme_ctx_t *ctx = acme_create(url, NULL);
TEST_ASSERT_NOT_NULL(ctx);
/* 内部字段不直接暴露,但至少能创建成功 */
acme_destroy(ctx);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_acme_create_destroy);
RUN_TEST(test_acme_create_with_key);
RUN_TEST(test_acme_get_directory);
RUN_TEST(test_acme_get_nonce);
RUN_TEST(test_acme_create_account);
RUN_TEST(test_acme_order_free);
RUN_TEST(test_acme_http_response_free);
RUN_TEST(test_acme_issue_certificate_params);
RUN_TEST(test_acme_directory_url);
return UNITY_END();
}