- 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 调用以匹配新签名
This commit is contained in:
parent
276fd1ca45
commit
f38effff49
8
Makefile
8
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)
|
||||
|
||||
99
acme.c
99
acme.c
@ -22,7 +22,9 @@
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/buffer.h>
|
||||
#include <openssl/x509v3.h>
|
||||
#include <openssl/x509.h>
|
||||
#include <curl/curl.h>
|
||||
#include "platform.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
@ -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) {
|
||||
|
||||
20
acme.h
20
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;
|
||||
|
||||
9
cocoon.h
9
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 === */
|
||||
|
||||
106
config.c
106
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;
|
||||
}
|
||||
|
||||
3
config.h
3
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 */
|
||||
|
||||
3
main.c
3
main.c
@ -89,6 +89,7 @@ static void print_usage(const char *prog) {
|
||||
printf(" --auth-pass <pass> Basic Auth 密码\n");
|
||||
printf(" --rate-limit <n> 每秒最大请求数(限流)\n");
|
||||
printf(" --plugin <path> 加载插件(可多次指定)\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);
|
||||
|
||||
121
server.c
121
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);
|
||||
}
|
||||
|
||||
|
||||
271
tests/unit/test_acme_config.c
Normal file
271
tests/unit/test_acme_config.c
Normal file
@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user