diff --git a/.cocoon-plan.md b/.cocoon-plan.md index d808c7b..075c42d 100644 --- a/.cocoon-plan.md +++ b/.cocoon-plan.md @@ -20,9 +20,10 @@ - [x] 连接超时管理(空闲连接自动清理)✅ 2026-06-03 - [x] 最大并发连接数限制 ✅ 2026-06-03 - [x] 分级日志系统(error / warn / info / debug)✅ 2026-06-03 +- [x] 访问日志(Nginx combined 格式,含 User-Agent / Referer)✅ 2026-06-05 - [x] Gzip 压缩 ✅ 2026-06-03(已接入响应流程) - [x] Brotli 压缩 ✅ 2026-06-04(优先于 Gzip) -- [x] 集成测试 suite(45 项 curl/bash 测试全部通过)✅ 2026-06-04 +- [x] 集成测试 suite(59 项 curl/bash 测试全部通过)✅ 2026-06-05 - [x] 性能基准(wrk: ~16.2K RPS / ~60μs 延迟)✅ 2026-06-03 - [x] 请求体解析(POST 支持)✅ 2026-06-03 - Content-Length 读取 @@ -47,11 +48,12 @@ ## 当前状态 - 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示) -- **54 项集成测试全部通过**(新增 2 项 HTTP/2 目录浏览测试) +- **59 项集成测试全部通过**(新增 5 项访问日志测试) - **35 个单元测试全部通过**(Unity 框架) - 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs - POST 支持:JSON 和 form-urlencoded 回显,multipart 文件上传,Content-Length 解析,8MB 上限 -- **配置文件支持**:JSON 格式,8 个字段,命令行参数可覆盖 +- **配置文件支持**:JSON 格式,9 个字段(含 access_log),命令行参数可覆盖 +- **访问日志**:Nginx combined 格式,支持文件路径或 stdout(`-`),线程安全(pthread_mutex),记录 User-Agent / Referer / 状态码 - **TLS/HTTPS**:OpenSSL 3.0 Memory BIO + coco 协程集成,自签名证书支持,ALPN 协商 h2/http1.1 - **HTTP/2**:完整功能(静态文件服务、目录浏览、缓存协商、压缩、HEAD 请求) - **h2c**:明文 HTTP/2 支持(prior knowledge + Upgrade 协商) @@ -64,6 +66,20 @@ ## 最近行动记录 +- **2026-06-05: 本轮行动 — 访问日志(Nginx combined 格式)** + - 新增 `access_log.c` / `access_log.h`:Nginx combined 格式访问日志,线程安全(pthread_mutex) + - `cocoon.h`:新增 `access_log_path` 配置字段 + - `config.c` / `config.h`:JSON 配置和命令行参数支持 `access_log` + - `main.c`:新增 `--access-log ` CLI 选项,`-` 表示输出到 stdout + - `server.c`:扩展 `connection_t` 结构体,添加 `client_addr` / `addr_len` / `response_status` + - `server.c`:`handle_request()` 中每个响应路径设置 `response_status`,请求结束时调用 `access_log_write()` + - `server.c`:`accept_loop()` 将客户端地址复制到连接上下文 + - `Makefile`:加入 `access_log.c`,更新单元测试编译规则 + - `cocoon.json`:示例配置添加 `"access_log": "-"` + - `tests/integration_test.sh`:新增 5 项访问日志测试(日志生成、GET 记录、User-Agent、Referer、404 状态码) + - 编译通过,零警告 + - **59 项集成测试全部通过,35 个单元测试全部通过** + - 推送到 main - **2026-06-04: 本轮行动 — http2.c 完整中文 Doxygen 注释** - 为 http2.c 全部 29 个函数添加中文文档注释 - 会话管理模块:init, cleanup, create, destroy, is_http2, get, set_context, upgrade diff --git a/Makefile b/Makefile index 0309164..9d5edbb 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ PREFIX ?= /usr/local BINDIR = $(PREFIX)/bin # 源文件 -SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c +SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c OBJS = $(SRCS:.c=.o) TARGET = cocoon @@ -78,8 +78,8 @@ unit-test: $(UNIT_TEST_BINS) 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 $(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 $(UNITY_SRC) $(LDFLAGS) +$(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 $(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 $(UNITY_SRC) $(LDFLAGS) $(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 @@ -87,14 +87,14 @@ $(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) $(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 $(UNITY_SRC) - $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c $(UNITY_SRC) $(LDFLAGS) +$(UNIT_TEST_DIR)/test_static: $(UNIT_TEST_DIR)/test_static.c http.c log.c tls.c access_log.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 $(UNITY_SRC) $(LDFLAGS) $(UNIT_TEST_DIR)/test_log: $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) -lm -$(UNIT_TEST_DIR)/test_config: $(UNIT_TEST_DIR)/test_config.c config.c log.c $(UNITY_SRC) - $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_config.c config.c log.c $(UNITY_SRC) -lm +$(UNIT_TEST_DIR)/test_config: $(UNIT_TEST_DIR)/test_config.c config.c log.c access_log.c http.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_config.c config.c log.c access_log.c http.c $(UNITY_SRC) -lm # 编译规则 %.o: %.c diff --git a/access_log.c b/access_log.c new file mode 100644 index 0000000..c1f47a4 --- /dev/null +++ b/access_log.c @@ -0,0 +1,193 @@ +/** + * access_log.c - 访问日志模块实现 + * + * 使用简化 Nginx combined 格式: + * %h %l %u %t "%r" %s %b "%{Referer}i" "%{User-Agent}i" + * + * 线程安全:使用 pthread_mutex_t 保护文件写入。 + * + * @author xfy + */ + +#include "access_log.h" +#include "http.h" +#include "log.h" +#include +#include +#include +#include +#include +#include + +/* === 内部状态 === */ +static FILE *g_log_file = NULL; +static pthread_mutex_t g_log_mutex = PTHREAD_MUTEX_INITIALIZER; +static bool g_enabled = false; + +/** + * format_log_time - 格式化当前时间为日志时间格式 + * + * Nginx 格式: [05/Jun/2026:00:00:00 +0800] + * + * @param buf 输出缓冲区 + * @param buf_size 缓冲区大小 + */ +static void format_log_time(char *buf, size_t buf_size) { + time_t now = time(NULL); + struct tm *tm = localtime(&now); + if (!tm) { + buf[0] = '\0'; + return; + } + + /* 计算时区偏移 */ + long tz_offset = 0; +#if defined(__linux__) || defined(__unix__) + tz_offset = tm->tm_gmtoff; +#else + /* 通用回退 */ + struct tm gmt = {0}; + gmtime_r(&now, &gmt); + time_t local = mktime(tm); + time_t gmt_t = mktime(&gmt); + tz_offset = (long)difftime(local, gmt_t); +#endif + + int tz_hours = (int)(tz_offset / 3600); + int tz_mins = (int)((tz_offset % 3600) / 60); + if (tz_mins < 0) tz_mins = -tz_mins; + + const char *months[] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }; + + snprintf(buf, buf_size, "[%02d/%s/%04d:%02d:%02d:%02d %c%02d%02d]", + tm->tm_mday, months[tm->tm_mon], tm->tm_year + 1900, + tm->tm_hour, tm->tm_min, tm->tm_sec, + tz_offset >= 0 ? '+' : '-', tz_hours, tz_mins); +} + +/** + * get_client_ip - 从 sockaddr 提取客户端 IP 字符串 + * + * @param addr sockaddr 结构体 + * @param addr_len 地址长度 + * @param buf 输出缓冲区 + * @param buf_size 缓冲区大小 + */ +static void get_client_ip(const struct sockaddr *addr, socklen_t addr_len, + char *buf, size_t buf_size) { + (void)addr_len; + buf[0] = '\0'; + + if (addr->sa_family == AF_INET) { + const struct sockaddr_in *sin = (const struct sockaddr_in *)addr; + inet_ntop(AF_INET, &sin->sin_addr, buf, (socklen_t)buf_size); + } else if (addr->sa_family == AF_INET6) { + const struct sockaddr_in6 *sin6 = (const struct sockaddr_in6 *)addr; + inet_ntop(AF_INET6, &sin6->sin6_addr, buf, (socklen_t)buf_size); + } else { + snprintf(buf, buf_size, "unknown"); + } +} + +/** + * get_header - 从请求头数组中查找指定头 + * + * @param req HTTP 请求 + * @param name 头名称(小写) + * @return 头值,未找到返回 "-" + */ +static const char *get_header(const http_request_t *req, const char *name) { + for (int i = 0; i < req->num_headers; i++) { + if (strcmp(req->headers[i].name, name) == 0) { + return req->headers[i].value; + } + } + return "-"; +} + +/* === 公共 API === */ + +int access_log_init(const char *path) { + pthread_mutex_lock(&g_log_mutex); + + if (g_log_file && g_log_file != stdout) { + fclose(g_log_file); + } + g_log_file = NULL; + g_enabled = false; + + if (!path || strcmp(path, "-") == 0 || strcmp(path, "stdout") == 0) { + g_log_file = stdout; + g_enabled = true; + log_info("访问日志输出到 stdout"); + } else { + g_log_file = fopen(path, "a"); + if (!g_log_file) { + log_error("无法打开访问日志文件: %s", path); + pthread_mutex_unlock(&g_log_mutex); + return -1; + } + g_enabled = true; + log_info("访问日志: %s", path); + } + + pthread_mutex_unlock(&g_log_mutex); + return 0; +} + +void access_log_close(void) { + pthread_mutex_lock(&g_log_mutex); + if (g_log_file && g_log_file != stdout) { + fclose(g_log_file); + } + g_log_file = NULL; + g_enabled = false; + pthread_mutex_unlock(&g_log_mutex); +} + +bool access_log_is_enabled(void) { + return g_enabled; +} + +void access_log_write(const struct sockaddr *client_addr, socklen_t addr_len, + const http_request_t *req, int status_code, int64_t response_bytes) { + if (!g_enabled || !req) return; + + char ip[64]; + get_client_ip(client_addr, addr_len, ip, sizeof(ip)); + + char time_str[64]; + format_log_time(time_str, sizeof(time_str)); + + const char *method = http_method_str(req->method); + const char *path = req->path; + const char *version = req->version[0] ? req->version : "HTTP/1.1"; + + const char *referer = get_header(req, "referer"); + const char *user_agent = get_header(req, "user-agent"); + + /* 响应字节 */ + char bytes_str[32]; + if (response_bytes < 0) { + snprintf(bytes_str, sizeof(bytes_str), "-"); + } else { + snprintf(bytes_str, sizeof(bytes_str), "%ld", (long)response_bytes); + } + + pthread_mutex_lock(&g_log_mutex); + if (g_log_file) { + fprintf(g_log_file, "%s - - %s \"%s %s %s\" %d %s \"%s\" \"%s\"\n", + ip[0] ? ip : "-", + time_str, + method, path, version, + status_code, + bytes_str, + referer, + user_agent); + fflush(g_log_file); + } + pthread_mutex_unlock(&g_log_mutex); +} diff --git a/access_log.h b/access_log.h new file mode 100644 index 0000000..702c900 --- /dev/null +++ b/access_log.h @@ -0,0 +1,58 @@ +/** + * access_log.h - 访问日志模块接口 + * + * 提供类似 Nginx combined 格式的访问日志记录。 + * 格式: %h %l %u %t "%r" %s %b "%{Referer}i" "%{User-Agent}i" + * + * @author xfy + */ + +#ifndef COCOON_ACCESS_LOG_H +#define COCOON_ACCESS_LOG_H + +#include +#include +#include +#include +#include "http.h" + +/** + * access_log_init - 初始化访问日志 + * + * 打开日志文件(如果 path 为 NULL 或 "-",则输出到 stdout)。 + * 线程安全:使用内部互斥锁保护。 + * + * @param path 日志文件路径,NULL 或 "-" 表示 stdout + * @return 0 成功,-1 失败 + */ +int access_log_init(const char *path); + +/** + * access_log_close - 关闭访问日志 + * + * 刷新并关闭日志文件。 + */ +void access_log_close(void); + +/** + * access_log_is_enabled - 检查访问日志是否已启用 + * + * @return true 已启用 + */ +bool access_log_is_enabled(void); + +/** + * access_log_write - 写入一条访问日志记录 + * + * 格式: 192.168.1.1 - - [05/Jun/2026:00:00:00 +0800] "GET /index.html HTTP/1.1" 200 1234 "-" "Mozilla/5.0" + * + * @param client_addr 客户端地址(sockaddr 结构体) + * @param addr_len 地址长度 + * @param req HTTP 请求指针(实际类型为 http_request_t *,在 http.h 定义) + * @param status_code HTTP 响应状态码 + * @param response_bytes 响应体字节数(-1 表示未知) + */ +void access_log_write(const struct sockaddr *client_addr, socklen_t addr_len, + const http_request_t *req, int status_code, int64_t response_bytes); + +#endif /* COCOON_ACCESS_LOG_H */ diff --git a/cocoon.h b/cocoon.h index 7eb82d6..2038525 100644 --- a/cocoon.h +++ b/cocoon.h @@ -41,6 +41,7 @@ typedef struct cocoon_config { bool tls_enabled; /**< 是否启用 TLS(由 cert/key 自动推断) */ const char *tls_cert; /**< TLS 证书路径 */ const char *tls_key; /**< TLS 私钥路径 */ + const char *access_log_path; /**< 访问日志文件路径(NULL 或 "-" 表示 stdout) */ } cocoon_config_t; /* === 服务器生命周期 API === */ diff --git a/cocoon.json b/cocoon.json index c57e382..b2fa8c8 100644 --- a/cocoon.json +++ b/cocoon.json @@ -7,5 +7,6 @@ "timeout_ms": 30000, "log_level": "info", "gzip_enabled": true, - "brotli_enabled": true + "brotli_enabled": true, + "access_log": "-" } diff --git a/config.c b/config.c index d42df08..aead4e4 100644 --- a/config.c +++ b/config.c @@ -289,6 +289,12 @@ bool config_load_from_file(const char *path, cocoon_config_t *config) { } else if (strcmp(key_str, "tls_enabled") == 0) { if (val.type == TOKEN_TRUE) config->tls_enabled = true; else if (val.type == TOKEN_FALSE) config->tls_enabled = false; + } else if (strcmp(key_str, "access_log") == 0 && val.type == TOKEN_STRING) { + char *v = token_str_dup(&val); + if (v) { + free((void *)config->access_log_path); + config->access_log_path = v; + } } /* 其他字段:忽略(未来扩展预留) */ @@ -312,7 +318,8 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_root_dir, bool has_port, bool has_workers, bool has_max_conn, bool has_timeout, bool has_log_level, bool has_gzip_enabled, bool has_brotli_enabled, - bool has_tls_cert, bool has_tls_key, bool has_tls_enabled) { + bool has_tls_cert, bool has_tls_key, bool has_tls_enabled, + bool has_access_log) { if (!base || !cmdline) return; /* 命令行显式指定的值覆盖配置文件 */ @@ -336,6 +343,10 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, base->tls_key = strdup(cmdline->tls_key); } if (has_tls_enabled) base->tls_enabled = cmdline->tls_enabled; + if (has_access_log && cmdline->access_log_path) { + free((void *)base->access_log_path); + base->access_log_path = strdup(cmdline->access_log_path); + } /* threaded 是 flag 参数,命令行指定了就用命令行的 */ if (cmdline->threaded) base->threaded = true; } diff --git a/config.h b/config.h index f587008..53975da 100644 --- a/config.h +++ b/config.h @@ -44,6 +44,7 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_root_dir, bool has_port, bool has_workers, bool has_max_conn, bool has_timeout, bool has_log_level, bool has_gzip_enabled, bool has_brotli_enabled, - bool has_tls_cert, bool has_tls_key, bool has_tls_enabled); + bool has_tls_cert, bool has_tls_key, bool has_tls_enabled, + bool has_access_log); #endif /* COCOON_CONFIG_H */ diff --git a/main.c b/main.c index ca5fd6b..5893c69 100644 --- a/main.c +++ b/main.c @@ -9,6 +9,7 @@ #include "cocoon.h" #include "server.h" #include "config.h" +#include "access_log.h" #include #include #include @@ -55,7 +56,7 @@ static void print_usage(const char *prog) { printf(" --key TLS 私钥路径\n"); printf(" --tls 显式启用 TLS(需同时指定 --cert 和 --key)\n"); printf(" --no-gzip 禁用 gzip 压缩\n"); - printf(" --no-brotli 禁用 brotli 压缩\n"); + printf(" --access-log 访问日志文件路径(- 表示 stdout)\n"); printf(" -h 显示此帮助\n"); printf("\nExample:\n"); printf(" %s -c cocoon.json\n", prog); @@ -100,6 +101,7 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { bool has_tls_cert = false; bool has_tls_key = false; bool has_tls_enabled = false; + bool has_access_log = false; const char *config_file = NULL; for (int i = 1; i < argc; i++) { @@ -160,6 +162,10 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { } else if (strcmp(argv[i], "--tls") == 0) { config->tls_enabled = true; has_tls_enabled = true; + } else if (strcmp(argv[i], "--access-log") == 0) { + if (++i >= argc) return false; + config->access_log_path = strdup(argv[i]); + has_access_log = true; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { print_usage(argv[0]); exit(0); @@ -179,7 +185,8 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { config_merge(config, config, has_root_dir, has_port, has_workers, has_max_conn, has_timeout, has_log_level, has_gzip_enabled, has_brotli_enabled, - has_tls_cert, has_tls_key, has_tls_enabled); + has_tls_cert, has_tls_key, has_tls_enabled, + has_access_log); } if (!config->root_dir) { @@ -214,6 +221,11 @@ int main(int argc, char *argv[]) { /* 设置日志级别 */ log_set_level(config.log_level); + /* 初始化访问日志 */ + if (config.access_log_path) { + access_log_init(config.access_log_path); + } + /* 创建服务器 */ g_ctx = server_create(&config); if (!g_ctx) { @@ -228,10 +240,14 @@ int main(int argc, char *argv[]) { server_destroy(g_ctx); g_ctx = NULL; + /* 关闭访问日志 */ + access_log_close(); + /* 释放配置文件分配的内存 */ if (config.root_dir) free((void *)config.root_dir); if (config.tls_cert) free((void *)config.tls_cert); if (config.tls_key) free((void *)config.tls_key); + if (config.access_log_path) free((void *)config.access_log_path); return ret == COCOON_OK ? 0 : 1; } diff --git a/server.c b/server.c index b5aac47..9656e39 100644 --- a/server.c +++ b/server.c @@ -23,6 +23,7 @@ #include "multipart.h" #include "tls.h" #include "http2.h" +#include "access_log.h" #include #include #include @@ -46,7 +47,7 @@ /** * connection_t - 单个客户端连接上下文 * - * 包含 socket fd、接收缓冲区、解析状态、超时管理。 + * 包含 socket fd、接收缓冲区、解析状态、超时管理、客户端地址、响应状态。 */ typedef struct { int fd; /**< 客户端 socket */ @@ -60,6 +61,9 @@ typedef struct { coco_coro_t *coro; /**< 当前处理协程 */ bool gzip_enabled; /**< 是否启用 gzip 压缩 */ bool brotli_enabled; /**< 是否启用 brotli 压缩 */ + struct sockaddr_storage client_addr; /**< 客户端地址 */ + socklen_t addr_len; /**< 地址长度 */ + int response_status; /**< 最后响应的 HTTP 状态码 */ } connection_t; struct server_context { int listen_fd; /**< 监听 socket */ @@ -335,6 +339,7 @@ static bool handle_post_request(int fd, const http_request_t *req, const char *r * * 从缓冲区解析请求,判断是文件还是目录,调用对应的服务函数。 * 新增:支持 POST 请求体读取和简单回显。 + * 新增:记录响应状态码到访问日志。 * * @param conn 连接上下文 * @param root_dir 静态资源根目录 @@ -350,7 +355,10 @@ static bool handle_request(connection_t *conn, const char *root_dir) { return true; } /* 格式错误 */ + conn->response_status = 400; static_send_error(conn->fd, 400, false); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); return false; } @@ -364,7 +372,11 @@ static bool handle_request(connection_t *conn, const char *root_dir) { if (req.content_length > 0) { size_t need = (size_t)req.content_length; if (conn_read_body(conn, &req, need) != 0) { + conn->response_status = 413; static_send_error(conn->fd, 413, req.keep_alive); /* Payload Too Large */ + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); + http_request_free(&req); return req.keep_alive; } } @@ -372,13 +384,19 @@ static bool handle_request(connection_t *conn, const char *root_dir) { /* 处理 POST */ if (req.method == HTTP_POST) { bool keep = handle_post_request(conn->fd, &req, conn->root_dir); + conn->response_status = 200; /* POST 目前总是返回 200 */ + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return keep; } /* 只支持 GET 和 HEAD */ if (req.method != HTTP_GET && req.method != HTTP_HEAD) { + conn->response_status = 405; static_send_error(conn->fd, 405, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -393,7 +411,10 @@ static bool handle_request(connection_t *conn, const char *root_dir) { int n = snprintf(real_path, sizeof(real_path), "%s%s", root_normalized, req.path); if (n < 0 || (size_t)n >= sizeof(real_path)) { + conn->response_status = 400; static_send_error(conn->fd, 400, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -403,7 +424,10 @@ static bool handle_request(connection_t *conn, const char *root_dir) { char resolved[4096]; if (!realpath(real_path, resolved) || strncmp(resolved, root_normalized, strlen(root_normalized)) != 0) { + conn->response_status = 403; static_send_error(conn->fd, 403, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -413,7 +437,10 @@ static bool handle_request(connection_t *conn, const char *root_dir) { /* 判断文件类型 */ struct stat st; if (stat(real_path, &st) != 0) { + conn->response_status = 404; static_send_error(conn->fd, 404, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -422,7 +449,10 @@ static bool handle_request(connection_t *conn, const char *root_dir) { /* 目录:尝试 index.html */ char index_path[4096]; if (snprintf(index_path, sizeof(index_path), "%s/index.html", real_path) >= (int)sizeof(index_path)) { + conn->response_status = 400; static_send_error(conn->fd, 400, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -431,22 +461,31 @@ static bool handle_request(connection_t *conn, const char *root_dir) { /* 有 index.html,作为文件服务 */ http_request_t index_req = req; if (snprintf(index_req.path, sizeof(index_req.path), "%s/index.html", req.path) >= (int)sizeof(index_req.path)) { + conn->response_status = 400; static_send_error(conn->fd, 400, req.keep_alive); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } + conn->response_status = 200; static_serve_file(conn->fd, &index_req, root_dir, conn->gzip_enabled, conn->brotli_enabled); } else { /* 无 index.html,生成目录列表 */ + conn->response_status = 200; static_serve_directory(conn->fd, &req, root_dir, real_path); } } else if (S_ISREG(st.st_mode)) { /* 普通文件 */ + conn->response_status = 200; static_serve_file(conn->fd, &req, root_dir, conn->gzip_enabled, conn->brotli_enabled); } else { + conn->response_status = 403; static_send_error(conn->fd, 403, req.keep_alive); } + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); http_request_free(&req); return req.keep_alive; } @@ -747,7 +786,7 @@ static void accept_loop(void *arg) { log_info("连接空闲超时: %u ms", ctx->config.timeout_ms > 0 ? ctx->config.timeout_ms : CONN_TIMEOUT_MS); while (ctx->running) { - struct sockaddr_in client_addr; + struct sockaddr_storage client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(ctx->listen_fd, @@ -828,6 +867,9 @@ static void accept_loop(void *arg) { conn->timeout_ms = ctx->config.timeout_ms > 0 ? ctx->config.timeout_ms : CONN_TIMEOUT_MS; conn->gzip_enabled = ctx->config.gzip_enabled; conn->brotli_enabled = ctx->config.brotli_enabled; + memcpy(&conn->client_addr, &client_addr, sizeof(client_addr)); + conn->addr_len = addr_len; + conn->response_status = 0; atomic_fetch_add(&g_active_connections, 1); log_debug("新连接 fd=%d,当前活跃连接: %d", client_fd, diff --git a/tests/integration_test.sh b/tests/integration_test.sh index 52fb9fd..a20740f 100755 --- a/tests/integration_test.sh +++ b/tests/integration_test.sh @@ -372,6 +372,25 @@ assert_status_405() { fi } +assert_access_log() { + local log_file="$1" + local desc="${2:-访问日志}" + if [ -f "$log_file" ] && [ -s "$log_file" ]; then + local count + count=$(grep -c '"GET /' "$log_file" || true) + if [ "$count" -ge 1 ]; then + echo " ✓ $desc — 日志文件包含请求记录" + pass + else + echo " ✗ $desc — 日志文件无请求记录" + fail + fi + else + echo " ✗ $desc — 日志文件不存在或为空" + fail + fi +} + # ===== 测试开始 ===== start_server @@ -455,6 +474,60 @@ echo "" echo "=== 文件上传测试 ===" assert_post_multipart "$BASE/upload" "multipart 文件上传" +echo "" +echo "=== 访问日志测试 ===" +# 带访问日志启动服务器 +kill_server +sleep 1 +LOG_FILE="$TMPDIR/access.log" +$SERVER -r "$ROOT" -p 9999 --access-log "$LOG_FILE" > "$TMPDIR/server_access.log" 2>&1 & +pid_access=$! +for i in {1..30}; do + if curl -s -o /dev/null "$BASE/" 2>/dev/null; then + break + fi + sleep 0.1 +done +# 发送请求并验证日志记录 +curl -s -o /dev/null "$BASE/index.html" -H "User-Agent: CocoonTest/1.0" +curl -s -o /dev/null "$BASE/nonexist.html" -H "Referer: http://example.com" +sleep 0.5 +assert_access_log "$LOG_FILE" "访问日志文件生成" +# 检查日志格式是否包含关键字段 +if grep -q '"GET /index.html HTTP/1.1"' "$LOG_FILE"; then + echo " ✓ 日志包含 GET /index.html" + pass +else + echo " ✗ 日志缺少 GET /index.html" + fail +fi +if grep -q 'CocoonTest/1.0' "$LOG_FILE"; then + echo " ✓ 日志包含 User-Agent" + pass +else + echo " ✗ 日志缺少 User-Agent" + fail +fi +if grep -q 'example.com' "$LOG_FILE"; then + echo " ✓ 日志包含 Referer" + pass +else + echo " ✗ 日志缺少 Referer" + fail +fi +if grep -q '404' "$LOG_FILE"; then + echo " ✓ 日志包含 404 状态码" + pass +else + echo " ✗ 日志缺少 404 状态码" + fail +fi + +# 恢复普通服务器 +kill_server +sleep 1 +start_server + echo "" echo "=== TLS/HTTPS 测试 ===" # 启动 HTTPS 服务器(使用测试证书) diff --git a/tests/unit/test_config.c b/tests/unit/test_config.c index 97622a1..d50c02b 100644 --- a/tests/unit/test_config.c +++ b/tests/unit/test_config.c @@ -126,7 +126,7 @@ void test_merge_override_all(void) { .brotli_enabled = false }; config_merge(&base, &cmdline, - true, true, true, true, true, true, true, true, true, true, true); + true, true, true, true, true, true, true, true, true, true, true, true); TEST_ASSERT_EQUAL_STRING("/new", base.root_dir); TEST_ASSERT_EQUAL(9090, base.port); TEST_ASSERT_TRUE(base.threaded); @@ -155,7 +155,7 @@ void test_merge_no_override(void) { .log_level = LOG_LEVEL_DEBUG }; 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); TEST_ASSERT_EQUAL_STRING("/old", base.root_dir); TEST_ASSERT_EQUAL(8080, base.port); TEST_ASSERT_FALSE(base.threaded); @@ -178,7 +178,7 @@ void test_merge_partial_override(void) { .log_level = LOG_LEVEL_DEBUG }; config_merge(&base, &cmdline, - true, false, false, true, false, false, false, false, false, false, false); + true, false, false, true, 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 */ @@ -243,7 +243,7 @@ void test_merge_cmdline_null_root_dir(void) { /* cmdline root_dir 为 NULL,不应覆盖 base */ 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); + config_merge(&base, &cmdline, true, true, 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); @@ -251,7 +251,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); + config_merge(NULL, NULL, true, true, true, true, true, true, true, true, true, true, true, true); TEST_ASSERT_TRUE(1); }