From f38effff49916fb0706715200e823a9caca1dec2 Mon Sep 17 00:00:00 2001 From: xfy911 Date: Tue, 16 Jun 2026 12:45:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ACME=20=E9=85=8D=E7=BD=AE=E9=9B=86?= =?UTF-8?q?=E6=88=90=20+=20=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=8D=8F=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cocoon_config_t 新增 ACME 字段(enabled, directory_url, email, domains, cert_path, key_path, renew_days),支持最多 8 个域名 - config.c: JSON 解析 acme 配置块、校验逻辑、config_merge 支持 - main.c: 新增 --acme 命令行开关 - server.c: server_start 启动 ACME 自动续期后台协程(检查周期 24h, 提前 renew_days 触发重新签发) - acme.c: 实现 acme_save_certificate 证书持久化、 acme_cert_days_until_expiry 过期天数检查 - acme.h: 新增 save_certificate / cert_days_until_expiry 接口声明 - 新增 tests/unit/test_acme_config.c:覆盖配置解析、校验、合并、 证书过期检查、证书保存共 6 项测试 - 修复 test_config.c 中 config_merge 调用以匹配新签名 --- Makefile | 8 +- acme.c | 99 ++++++++++++- acme.h | 20 +++ cocoon.h | 9 ++ config.c | 106 ++++++++++++- config.h | 3 +- main.c | 3 + server.c | 121 +++++++++++++++ tests/unit/test_acme_config.c | 271 ++++++++++++++++++++++++++++++++++ tests/unit/test_config.c | 10 +- 10 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_acme_config.c diff --git a/Makefile b/Makefile index 83b149c..6c9eb69 100644 --- a/Makefile +++ b/Makefile @@ -151,8 +151,12 @@ $(UNIT_TEST_DIR)/test_dashboard: $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c s $(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) +$(UNIT_TEST_DIR)/test_acme: $(UNIT_TEST_DIR)/test_acme.c acme.c log.c platform.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_acme.c acme.c log.c platform.c $(UNITY_SRC) $(LDFLAGS) + +# ACME 配置集成测试 +$(UNIT_TEST_DIR)/test_acme_config: $(UNIT_TEST_DIR)/test_acme_config.c config.c acme.c log.c access_log.c http.c platform.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_acme_config.c config.c acme.c log.c access_log.c http.c platform.c $(UNITY_SRC) $(LDFLAGS) # 安装 install: $(TARGET) diff --git a/acme.c b/acme.c index 6decc70..141702d 100644 --- a/acme.c +++ b/acme.c @@ -22,7 +22,9 @@ #include #include #include +#include #include +#include "platform.h" #include #include #include @@ -946,7 +948,102 @@ int acme_get_keyauth(acme_ctx_t *ctx, const char *token, char **out) { return 0; } -/* ===== 一键签发 ===== */ +/* ===== 证书保存与过期检查 ===== */ + +int acme_save_certificate(const char *cert_pem, const char *key_pem, + const char *cert_path, const char *key_path) { + if (!cert_pem || !key_pem || !cert_path || !key_path) return -1; + + /* 确保证书目录存在 */ + char cert_dir[512]; + snprintf(cert_dir, sizeof(cert_dir), "%s", cert_path); + char *last_slash = strrchr(cert_dir, '/'); + if (last_slash) { + *last_slash = '\0'; + cocoon_mkdir(cert_dir); + } + + /* 确保私钥目录存在 */ + char key_dir[512]; + snprintf(key_dir, sizeof(key_dir), "%s", key_path); + last_slash = strrchr(key_dir, '/'); + if (last_slash) { + *last_slash = '\0'; + cocoon_mkdir(key_dir); + } + + FILE *fp = fopen(cert_path, "w"); + if (!fp) { + log_error("无法打开证书文件写入: %s", cert_path); + return -1; + } + fwrite(cert_pem, 1, strlen(cert_pem), fp); + fclose(fp); + + fp = fopen(key_path, "w"); + if (!fp) { + log_error("无法打开私钥文件写入: %s", key_path); + return -1; + } + fwrite(key_pem, 1, strlen(key_pem), fp); + fclose(fp); + + log_info("证书已保存: %s", cert_path); + log_info("私钥已保存: %s", key_path); + return 0; +} + +int acme_cert_days_until_expiry(const char *cert_path) { + if (!cert_path || access(cert_path, R_OK) != 0) { + return -1; + } + + FILE *fp = fopen(cert_path, "r"); + if (!fp) return -1; + + X509 *cert = PEM_read_X509(fp, NULL, NULL, NULL); + fclose(fp); + if (!cert) return -1; + + ASN1_TIME *not_after = X509_get_notAfter(cert); + if (!not_after) { + X509_free(cert); + return -1; + } + + /* 解析 ASN1_TIME */ + struct tm tm = {0}; + const char *str = (const char *)not_after->data; + int year = 0, mon = 0, day = 0, hour = 0, min = 0, sec = 0; + if (not_after->length >= 13) { + /* UTCTime: YYMMDDHHMMSSZ (13 chars) or GeneralizedTime: YYYYMMDDHHMMSSZ (15 chars) */ + if (not_after->length >= 15 && str[0] == '2' && str[1] == '0') { + /* GeneralizedTime */ + sscanf(str, "%4d%2d%2d%2d%2d%2dZ", &year, &mon, &day, &hour, &min, &sec); + } else { + /* UTCTime */ + sscanf(str, "%2d%2d%2d%2d%2d%2dZ", &year, &mon, &day, &hour, &min, &sec); + year += (year >= 50) ? 1900 : 2000; + } + tm.tm_year = year - 1900; + tm.tm_mon = mon - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + } + + time_t expiry = timegm(&tm); + time_t now = time(NULL); + X509_free(cert); + + if (expiry == (time_t)-1) return -1; + + double diff = difftime(expiry, now); + return (int)(diff / 86400.0); +} + +/* ===== 一键签发(完整流程) ===== */ int acme_issue_certificate(acme_ctx_t *ctx, const char **domains, size_t num_domains, const char *email, char **cert_pem, char **key_pem) { diff --git a/acme.h b/acme.h index d996d6e..4f6f724 100644 --- a/acme.h +++ b/acme.h @@ -230,6 +230,26 @@ int acme_get_keyauth(acme_ctx_t *ctx, const char *token, char **out); int acme_issue_certificate(acme_ctx_t *ctx, const char **domains, size_t num_domains, const char *email, char **cert_pem, char **key_pem); +/** + * acme_save_certificate - 将证书和私钥保存到文件 + * + * @param cert_pem 证书 PEM 字符串 + * @param key_pem 私钥 PEM 字符串 + * @param cert_path 证书文件路径 + * @param key_path 私钥文件路径 + * @return 0 成功,-1 失败 + */ +int acme_save_certificate(const char *cert_pem, const char *key_pem, + const char *cert_path, const char *key_path); + +/** + * acme_cert_days_until_expiry - 检查证书剩余有效天数 + * + * @param cert_path 证书文件路径 + * @return 剩余天数,<0 表示证书不存在或已过期,0 表示今天过期 + */ +int acme_cert_days_until_expiry(const char *cert_path); + /* HTTP 请求辅助(内部使用,暴露给单元测试) */ typedef struct { int status; diff --git a/cocoon.h b/cocoon.h index 66a4eef..7ae8a3a 100644 --- a/cocoon.h +++ b/cocoon.h @@ -102,6 +102,15 @@ typedef struct cocoon_config { size_t cache_max_size; /**< 最大缓存总容量(字节,默认 64MB) */ uint32_t cache_ttl_seconds; /**< 缓存 TTL 秒数(默认 60) */ size_t cache_max_entry_size; /**< 单条缓存最大大小(字节,默认 1MB) */ + /* ACME 自动证书配置 */ + bool acme_enabled; /**< 是否启用 ACME 自动证书(默认 false) */ + char acme_directory_url[512]; /**< ACME 目录 URL(默认 Let's Encrypt 生产环境) */ + char acme_email[256]; /**< ACME 账户邮箱 */ + char acme_domains[8][256]; /**< 需要签发证书的域名列表 */ + size_t acme_num_domains; /**< 域名数量 */ + char acme_cert_path[512]; /**< 证书保存路径 */ + char acme_key_path[512]; /**< 私钥保存路径 */ + uint32_t acme_renew_days; /**< 到期前 N 天自动续期(默认 30) */ } cocoon_config_t; /* === 服务器生命周期 API === */ diff --git a/config.c b/config.c index 757365c..bbcef87 100644 --- a/config.c +++ b/config.c @@ -726,6 +726,78 @@ bool config_load_from_file(const char *path, cocoon_config_t *config) { } else { fprintf(stderr, "[Config] 第 %d 行: cache 期望对象\n", val.line); } + } else if (strcmp(key_str, "acme") == 0) { + if (val.type == TOKEN_LBRACE) { + while (1) { + token_t akey = parser_next_token(&p); + if (akey.type == TOKEN_RBRACE) break; + if (akey.type != TOKEN_STRING) { + parser_skip_value(&p); + continue; + } + char *ak_str = parser_expect_string(&p, akey); + token_t asep = parser_next_token(&p); + if (asep.type != TOKEN_COLON) { + free(ak_str); + parser_skip_value(&p); + continue; + } + token_t aval = parser_next_token(&p); + if (strcmp(ak_str, "enabled") == 0 && aval.type == TOKEN_TRUE) { + config->acme_enabled = true; + } else if (strcmp(ak_str, "enabled") == 0 && aval.type == TOKEN_FALSE) { + config->acme_enabled = false; + } else if (strcmp(ak_str, "directory_url") == 0 && aval.type == TOKEN_STRING) { + char *v = token_str_dup(&aval); + if (v) { + snprintf(config->acme_directory_url, sizeof(config->acme_directory_url), "%s", v); + free(v); + } + } else if (strcmp(ak_str, "email") == 0 && aval.type == TOKEN_STRING) { + char *v = token_str_dup(&aval); + if (v) { + snprintf(config->acme_email, sizeof(config->acme_email), "%s", v); + free(v); + } + } else if (strcmp(ak_str, "domains") == 0 && aval.type == TOKEN_LBRACKET) { + while (1) { + token_t ditem = parser_next_token(&p); + if (ditem.type == TOKEN_RBRACKET) break; + if (ditem.type == TOKEN_STRING && config->acme_num_domains < 8) { + char *v = token_str_dup(&ditem); + if (v) { + snprintf(config->acme_domains[config->acme_num_domains], sizeof(config->acme_domains[0]), "%s", v); + config->acme_num_domains++; + free(v); + } + } + token_t dsep = parser_next_token(&p); + if (dsep.type == TOKEN_RBRACKET) break; + if (dsep.type != TOKEN_COMMA) break; + } + } else if (strcmp(ak_str, "cert_path") == 0 && aval.type == TOKEN_STRING) { + char *v = token_str_dup(&aval); + if (v) { + snprintf(config->acme_cert_path, sizeof(config->acme_cert_path), "%s", v); + free(v); + } + } else if (strcmp(ak_str, "key_path") == 0 && aval.type == TOKEN_STRING) { + char *v = token_str_dup(&aval); + if (v) { + snprintf(config->acme_key_path, sizeof(config->acme_key_path), "%s", v); + free(v); + } + } else if (strcmp(ak_str, "renew_days") == 0 && aval.type == TOKEN_NUMBER) { + config->acme_renew_days = (uint32_t)token_to_long(&aval); + } + free(ak_str); + token_t asep2 = parser_next_token(&p); + if (asep2.type == TOKEN_RBRACE) break; + if (asep2.type != TOKEN_COMMA) break; + } + } else { + fprintf(stderr, "[Config] 第 %d 行: acme 期望对象\n", val.line); + } } /* 其他字段:忽略(未来扩展预留) */ free(key_str); @@ -911,6 +983,36 @@ bool config_validate(const cocoon_config_t *config, char *err_buf, size_t err_si } } + /* ACME 配置校验 */ + if (config->acme_enabled) { + if (config->acme_email[0] == '\0') { + SET_ERR("ACME 邮箱不能为空"); + return false; + } + if (config->acme_num_domains == 0) { + SET_ERR("ACME 域名列表不能为空"); + return false; + } + for (size_t i = 0; i < config->acme_num_domains; i++) { + if (config->acme_domains[i][0] == '\0') { + SET_ERR("ACME 域名不能为空"); + return false; + } + } + if (config->acme_cert_path[0] == '\0') { + SET_ERR("ACME 证书保存路径不能为空"); + return false; + } + if (config->acme_key_path[0] == '\0') { + SET_ERR("ACME 私钥保存路径不能为空"); + return false; + } + if (config->acme_renew_days == 0 || config->acme_renew_days > 90) { + SET_ERR("ACME renew_days 必须在 1-90 之间"); + return false; + } + } + #undef SET_ERR return true; } @@ -949,7 +1051,8 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_rate_limit, bool has_plugins, bool has_cache_enabled, bool has_cache_max_size, - bool has_cache_ttl_seconds, bool has_cache_max_entry_size) { + bool has_cache_ttl_seconds, bool has_cache_max_entry_size, + bool has_acme_enabled) { if (!base || !cmdline) return; /* 命令行显式指定的值覆盖配置文件 */ @@ -998,6 +1101,7 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, if (has_cache_max_size) base->cache_max_size = cmdline->cache_max_size; if (has_cache_ttl_seconds) base->cache_ttl_seconds = cmdline->cache_ttl_seconds; if (has_cache_max_entry_size) base->cache_max_entry_size = cmdline->cache_max_entry_size; + if (has_acme_enabled) base->acme_enabled = cmdline->acme_enabled; /* threaded 是 flag 参数,命令行指定了就用命令行的 */ if (cmdline->threaded) base->threaded = true; } diff --git a/config.h b/config.h index fdcf7e5..3bde123 100644 --- a/config.h +++ b/config.h @@ -63,6 +63,7 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_rate_limit, bool has_plugins, bool has_cache_enabled, bool has_cache_max_size, - bool has_cache_ttl_seconds, bool has_cache_max_entry_size); + bool has_cache_ttl_seconds, bool has_cache_max_entry_size, + bool has_acme_enabled); #endif /* COCOON_CONFIG_H */ diff --git a/main.c b/main.c index b6405a8..78e219e 100644 --- a/main.c +++ b/main.c @@ -89,6 +89,7 @@ static void print_usage(const char *prog) { printf(" --auth-pass Basic Auth 密码\n"); printf(" --rate-limit 每秒最大请求数(限流)\n"); printf(" --plugin 加载插件(可多次指定)\n"); + printf(" --acme 启用 ACME 自动证书\n"); printf("\nSignals:\n"); printf(" SIGHUP 热重载配置文件(无需重启服务器)\n"); printf(" SIGUSR1 热重载所有插件(无需重启服务器)\n"); @@ -207,6 +208,8 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { fprintf(stderr, "Error: 最多支持 %d 个插件\n", COCOON_MAX_PLUGINS); return false; } + } else if (strcmp(argv[i], "--acme") == 0) { + config->acme_enabled = true; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { print_usage(argv[0]); exit(0); diff --git a/server.c b/server.c index a798149..d9c00c3 100644 --- a/server.c +++ b/server.c @@ -1468,6 +1468,54 @@ server_context_t *server_create(const cocoon_config_t *config, const char *confi } } + /* 初始化 ACME 自动证书 */ + if (ctx->config.acme_enabled) { + const char *directory_url = ctx->config.acme_directory_url[0] ? + ctx->config.acme_directory_url : + "https://acme-v02.api.letsencrypt.org/directory"; + + /* 检查现有证书是否有效 */ + int days_left = acme_cert_days_until_expiry(ctx->config.acme_cert_path); + if (days_left >= (int)ctx->config.acme_renew_days) { + log_info("ACME: 现有证书仍有效(剩余 %d 天),跳过签发", days_left); + } else { + if (days_left < 0) { + log_info("ACME: 证书不存在或已过期,开始签发新证书"); + } else { + log_info("ACME: 证书即将过期(剩余 %d 天,阈值 %u 天),开始续期", days_left, ctx->config.acme_renew_days); + } + + ctx->acme = acme_create(directory_url, NULL); + if (ctx->acme) { + char *cert_pem = NULL; + char *key_pem = NULL; + + const char *domains[8]; + for (size_t i = 0; i < ctx->config.acme_num_domains; i++) { + domains[i] = ctx->config.acme_domains[i]; + } + + if (acme_issue_certificate(ctx->acme, domains, ctx->config.acme_num_domains, + ctx->config.acme_email, &cert_pem, &key_pem) == 0) { + if (acme_save_certificate(cert_pem, key_pem, + ctx->config.acme_cert_path, + ctx->config.acme_key_path) == 0) { + log_info("ACME: 证书签发并保存成功"); + } else { + log_error("ACME: 证书保存失败"); + } + free(cert_pem); + free(key_pem); + } else { + log_error("ACME: 证书签发失败"); + } + /* ACME 上下文继续保留,用于挑战响应和后续续期 */ + } else { + log_error("ACME: 客户端初始化失败"); + } + } + } + /* 启动主动健康检查 */ healthcheck_manager_init(&ctx->hc_manager); healthcheck_start(&ctx->hc_manager, &ctx->proxy_config, ctx->config.timeout_ms); @@ -1475,6 +1523,70 @@ server_context_t *server_create(const cocoon_config_t *config, const char *confi return ctx; } +/** + * acme_renewal_coroutine - ACME 证书自动续期后台协程 + * + * 每 24 小时检查一次证书有效期,到期前自动续期。 + * + * @param arg 服务器上下文指针 + */ +static void acme_renewal_coroutine(void *arg) { + server_context_t *ctx = (server_context_t *)arg; + if (!ctx || !ctx->config.acme_enabled) return; + + /* 如果 ACME 上下文未创建(证书已存在且有效),创建一个用于续期 */ + if (!ctx->acme) { + const char *directory_url = ctx->config.acme_directory_url[0] ? + ctx->config.acme_directory_url : + "https://acme-v02.api.letsencrypt.org/directory"; + ctx->acme = acme_create(directory_url, NULL); + if (!ctx->acme) { + log_error("ACME 续期: 客户端初始化失败"); + return; + } + } + + const uint32_t check_interval_ms = 24 * 60 * 60 * 1000; /* 24 小时 */ + + while (ctx->running) { + coco_sleep(check_interval_ms); + if (!ctx->running) break; + + int days_left = acme_cert_days_until_expiry(ctx->config.acme_cert_path); + if (days_left < 0 || days_left < (int)ctx->config.acme_renew_days) { + if (days_left < 0) { + log_info("ACME 续期: 证书已过期或不存在,开始重新签发"); + } else { + log_info("ACME 续期: 证书即将过期(剩余 %d 天),开始续期", days_left); + } + + char *cert_pem = NULL; + char *key_pem = NULL; + const char *domains[8]; + for (size_t i = 0; i < ctx->config.acme_num_domains; i++) { + domains[i] = ctx->config.acme_domains[i]; + } + + if (acme_issue_certificate(ctx->acme, domains, ctx->config.acme_num_domains, + ctx->config.acme_email, &cert_pem, &key_pem) == 0) { + if (acme_save_certificate(cert_pem, key_pem, + ctx->config.acme_cert_path, + ctx->config.acme_key_path) == 0) { + log_info("ACME 续期: 证书续期并保存成功"); + } else { + log_error("ACME 续期: 证书保存失败"); + } + free(cert_pem); + free(key_pem); + } else { + log_error("ACME 续期: 证书签发失败"); + } + } else { + log_debug("ACME 续期: 证书仍有效(剩余 %d 天),跳过", days_left); + } + } +} + /** * server_start - 启动服务器(阻塞) * @@ -1506,6 +1618,11 @@ int server_start(server_context_t *ctx) { /* 多线程模式下 listen_fd 需要非阻塞(用于 accept + poll 等待) */ set_nonblocking(ctx->listen_fd); + /* 启动 ACME 自动续期后台协程 */ + if (ctx->config.acme_enabled) { + coco_go(acme_renewal_coroutine, ctx); + } + /* 在主线程中运行 accept_loop */ accept_loop(ctx); @@ -1514,6 +1631,10 @@ int server_start(server_context_t *ctx) { coco_global_sched_stop(); } else { /* 单线程模式:listen_fd 保持阻塞 */ + /* 启动 ACME 自动续期后台协程 */ + if (ctx->config.acme_enabled) { + coco_go(acme_renewal_coroutine, ctx); + } accept_loop(ctx); } diff --git a/tests/unit/test_acme_config.c b/tests/unit/test_acme_config.c new file mode 100644 index 0000000..977e1d4 --- /dev/null +++ b/tests/unit/test_acme_config.c @@ -0,0 +1,271 @@ +/** + * test_acme_config.c - ACME 配置集成单元测试 + * + * 测试 ACME 配置字段解析、校验、合并和证书过期检查。 + * + * @author xfy + */ + +#include "unity.h" +#include "acme.h" +#include "config.h" +#include "cocoon.h" +#include "log.h" +#include +#include +#include + +void setUp(void) { + log_set_level(LOG_LEVEL_ERROR); +} + +void tearDown(void) { +} + +/** + * test_acme_config_parse - 测试 ACME 配置 JSON 解析 + */ +static void test_acme_config_parse(void) { + cocoon_config_t config = {0}; + config.port = 8080; + config.log_level = LOG_LEVEL_INFO; + config.gzip_enabled = true; + config.brotli_enabled = true; + + const char *json = "{" + "\"root_dir\":\"/var/www\"," + "\"acme\":{" + " \"enabled\":true," + " \"directory_url\":\"https://acme-staging-v02.api.letsencrypt.org/directory\"," + " \"email\":\"admin@example.com\"," + " \"domains\":[\"example.com\",\"www.example.com\"]," + " \"cert_path\":\"/etc/cocoon/cert.pem\"," + " \"key_path\":\"/etc/cocoon/key.pem\"," + " \"renew_days\":14" + "}" + "}"; + + FILE *fp = fopen("/tmp/test_acme_config.json", "w"); + TEST_ASSERT_NOT_NULL(fp); + fwrite(json, 1, strlen(json), fp); + fclose(fp); + + bool ok = config_load_from_file("/tmp/test_acme_config.json", &config); + TEST_ASSERT_TRUE(ok); + + TEST_ASSERT_TRUE(config.acme_enabled == true); + TEST_ASSERT_EQUAL_STRING("https://acme-staging-v02.api.letsencrypt.org/directory", config.acme_directory_url); + TEST_ASSERT_EQUAL_STRING("admin@example.com", config.acme_email); + TEST_ASSERT_EQUAL_size_t(2, config.acme_num_domains); + TEST_ASSERT_EQUAL_STRING("example.com", config.acme_domains[0]); + TEST_ASSERT_EQUAL_STRING("www.example.com", config.acme_domains[1]); + TEST_ASSERT_EQUAL_STRING("/etc/cocoon/cert.pem", config.acme_cert_path); + TEST_ASSERT_EQUAL_STRING("/etc/cocoon/key.pem", config.acme_key_path); + TEST_ASSERT_EQUAL_UINT(14, config.acme_renew_days); + + /* 校验 */ + char err_buf[256] = {0}; + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_TRUE(ok); + + free((void*)config.root_dir); +} + +/** + * test_acme_config_validation - 测试 ACME 配置校验 + */ +static void test_acme_config_validation(void) { + cocoon_config_t config = {0}; + config.port = 8080; + config.log_level = LOG_LEVEL_INFO; + config.gzip_enabled = true; + config.brotli_enabled = true; + config.root_dir = strdup("/var/www"); + config.acme_enabled = true; + + char err_buf[256] = {0}; + bool ok; + + /* 缺少邮箱 */ + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_NOT_NULL(strstr(err_buf, "邮箱")); + + /* 缺少域名 */ + strcpy(config.acme_email, "admin@example.com"); + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_NOT_NULL(strstr(err_buf, "域名")); + + /* 缺少证书路径 */ + strcpy(config.acme_domains[0], "example.com"); + config.acme_num_domains = 1; + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_NOT_NULL(strstr(err_buf, "证书")); + + /* 缺少私钥路径 */ + strcpy(config.acme_cert_path, "/etc/cocoon/cert.pem"); + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_NOT_NULL(strstr(err_buf, "私钥")); + + /* renew_days 越界 */ + strcpy(config.acme_key_path, "/etc/cocoon/key.pem"); + config.acme_renew_days = 0; + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_NOT_NULL(strstr(err_buf, "renew_days")); + + config.acme_renew_days = 91; + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_FALSE(ok); + + /* 完整配置通过校验 */ + config.acme_renew_days = 30; + ok = config_validate(&config, err_buf, sizeof(err_buf)); + TEST_ASSERT_TRUE(ok); + + free((void*)config.root_dir); +} + +/** + * test_acme_config_defaults - 测试 ACME 配置默认值 + */ +static void test_acme_config_defaults(void) { + cocoon_config_t config = {0}; + config.port = 8080; + config.log_level = LOG_LEVEL_INFO; + config.gzip_enabled = true; + config.brotli_enabled = true; + config.root_dir = strdup("/var/www"); + + char err_buf[256] = {0}; + bool ok = config_validate(&config, err_buf, sizeof(err_buf)); + /* 未启用 ACME,不应校验 ACME 字段 */ + TEST_ASSERT_TRUE(ok); + + free((void*)config.root_dir); +} + +/** + * test_acme_cert_expiry - 测试证书过期检查 + */ +static void test_acme_cert_expiry(void) { + /* 不存在的证书 */ + int days = acme_cert_days_until_expiry("/nonexistent/cert.pem"); + TEST_ASSERT_TRUE(days < 0); + + /* 生成一个自签名证书用于测试 */ + const char *cert_path = "/tmp/test_acme_cert.pem"; + const char *key_path = "/tmp/test_acme_key.pem"; + + FILE *fp = fopen(cert_path, "w"); + if (!fp) { + /* 无法创建测试证书,跳过 */ + return; + } + fclose(fp); + + /* 空文件应该返回 -1 */ + days = acme_cert_days_until_expiry(cert_path); + TEST_ASSERT_TRUE(days < 0); + + /* 使用 OpenSSL 生成一个有效期 90 天的自签名证书 */ + char cmd[512]; + snprintf(cmd, sizeof(cmd), + "openssl req -x509 -newkey rsa:2048 -keyout %s -out %s " + "-days 90 -nodes -subj '/CN=test.example.com' 2>/dev/null", + key_path, cert_path); + int ret = system(cmd); + if (ret != 0) { + /* openssl 不可用,跳过 */ + remove(cert_path); + return; + } + + days = acme_cert_days_until_expiry(cert_path); + /* 应该接近 90 天 */ + TEST_ASSERT_TRUE(days >= 85 && days <= 90); + + remove(cert_path); + remove(key_path); +} + +/** + * test_acme_save_certificate - 测试证书保存 + */ +static void test_acme_save_certificate(void) { + const char *cert_pem = "-----BEGIN CERTIFICATE-----\n" + "MIIBkTCB+wIJAKHBfpE"; + const char *key_pem = "-----BEGIN PRIVATE KEY-----\n" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg"; + + const char *cert_path = "/tmp/test_save_cert.pem"; + const char *key_path = "/tmp/test_save_key.pem"; + + int ret = acme_save_certificate(cert_pem, key_pem, cert_path, key_path); + TEST_ASSERT_EQUAL_INT(0, ret); + + /* 验证文件存在且内容正确 */ + FILE *fp = fopen(cert_path, "r"); + TEST_ASSERT_NOT_NULL(fp); + char buf[256]; + size_t n = fread(buf, 1, sizeof(buf), fp); + fclose(fp); + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_INT(0, strncmp(buf, cert_pem, strlen(cert_pem))); + + fp = fopen(key_path, "r"); + TEST_ASSERT_NOT_NULL(fp); + n = fread(buf, 1, sizeof(buf), fp); + fclose(fp); + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_INT(0, strncmp(buf, key_pem, strlen(key_pem))); + + remove(cert_path); + remove(key_path); +} + +/** + * test_acme_config_merge - 测试 ACME 配置合并 + */ +static void test_acme_config_merge(void) { + cocoon_config_t base = {0}; + base.port = 8080; + base.log_level = LOG_LEVEL_INFO; + base.gzip_enabled = true; + base.brotli_enabled = true; + base.root_dir = strdup("/var/www"); + + cocoon_config_t cmdline = {0}; + cmdline.acme_enabled = true; + + config_merge(&base, &cmdline, + false, false, false, false, false, false, + false, false, false, false, false, + false, false, false, false, + false, false, + false, false, false, false, + true); /* has_acme_enabled */ + + TEST_ASSERT_TRUE(base.acme_enabled == true); + + free((void*)base.root_dir); +} + +/** + * main - 测试入口 + */ +int main(void) { + UNITY_BEGIN(); + + RUN_TEST(test_acme_config_parse); + RUN_TEST(test_acme_config_validation); + RUN_TEST(test_acme_config_defaults); + RUN_TEST(test_acme_cert_expiry); + RUN_TEST(test_acme_save_certificate); + RUN_TEST(test_acme_config_merge); + + return UNITY_END(); +} diff --git a/tests/unit/test_config.c b/tests/unit/test_config.c index a9fa341..6fdc1d9 100644 --- a/tests/unit/test_config.c +++ b/tests/unit/test_config.c @@ -306,7 +306,7 @@ void test_merge_override_all(void) { }; config_merge(&base, &cmdline, true, true, true, true, true, true, true, true, true, true, true, true, - false, false, false, false, false, false, false, false, false); + false, false, false, false, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/new", base.root_dir); TEST_ASSERT_EQUAL(9090, base.port); TEST_ASSERT_TRUE(base.threaded); @@ -336,7 +336,7 @@ void test_merge_no_override(void) { }; config_merge(&base, &cmdline, false, false, false, false, false, false, false, false, false, false, false, false, - false, false, false, false, false, false, false, false, false); + false, false, false, false, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/old", base.root_dir); TEST_ASSERT_EQUAL(8080, base.port); TEST_ASSERT_FALSE(base.threaded); @@ -360,7 +360,7 @@ void test_merge_partial_override(void) { }; config_merge(&base, &cmdline, true, false, false, true, false, false, false, false, false, false, false, false, - false, false, false, false, false, false, false, false, false); + false, false, false, false, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/new", base.root_dir); /* overridden */ TEST_ASSERT_EQUAL(8080, base.port); /* not overridden */ TEST_ASSERT_EQUAL(2, base.num_workers); /* not overridden */ @@ -426,7 +426,7 @@ void test_merge_cmdline_null_root_dir(void) { cocoon_config_t base = {.root_dir = strdup("/old"), .port = 8080}; cocoon_config_t cmdline = {.root_dir = NULL, .port = 9090}; config_merge(&base, &cmdline, true, true, false, false, false, false, false, false, false, false, false, false, - false, false, false, false, false, false, false, false, false); + false, false, false, false, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/old", base.root_dir); /* NULL 不覆盖 */ TEST_ASSERT_EQUAL(9090, base.port); /* port 覆盖 */ free((void *)base.root_dir); @@ -435,7 +435,7 @@ void test_merge_cmdline_null_root_dir(void) { void test_merge_null_safety(void) { /* 不应 crash */ config_merge(NULL, NULL, true, true, true, true, true, true, true, true, true, true, true, true, - true, true, true, true, true, true, true, true, false); + true, true, true, true, true, true, true, true, false, false); TEST_ASSERT_TRUE(1); }