fix(proxy): 修复连接池 HTTP/1.0 兼容与配置合并 bug

- proxy.c: 当后端发送 Connection: close 或关闭连接时,不再归还已关闭连接
  到池中。避免复用已关闭连接导致请求失败。
- http2.c: HTTP/2 代理检测到 HTTP/1.0 响应时默认关闭连接,不再错误归还。
- main.c: 修复 -c 配置文件与 --cert/--key 同时使用时 config_merge 的
  use-after-free 问题。先加载配置文件,再用命令行参数覆盖。
- tests/http10_backend.py: 新增 HTTP/1.0 后端模拟器,用于测试连接池兼容。
- tests/integration_test.sh: 添加 HTTP/1.0 后端兼容测试,验证两次连续请求
  均成功(第二次必须新建连接)。
This commit is contained in:
xfy911 2026-06-09 12:35:26 +08:00
parent 25a0b100c1
commit 90601e0f67
5 changed files with 201 additions and 73 deletions

View File

@ -1584,6 +1584,7 @@ static void http2_serve_proxy(http2_session_t *h2, http2_stream_data_t *stream)
/* 判断后端是否发送 Connection: close */
bool backend_wants_close = false;
bool is_http10 = strncmp(response_buf, "HTTP/1.0", 8) == 0;
{
const char *p = headers;
while (*p) {
@ -1592,6 +1593,8 @@ static void http2_serve_proxy(http2_session_t *h2, http2_stream_data_t *stream)
while (*p == ' ' || *p == '\t') p++;
if (strncasecmp(p, "close", 5) == 0) {
backend_wants_close = true;
} else if (strncasecmp(p, "keep-alive", 10) == 0) {
backend_wants_close = false; /* 显式 keep-alive */
}
break;
}
@ -1600,6 +1603,10 @@ static void http2_serve_proxy(http2_session_t *h2, http2_stream_data_t *stream)
p = nl + 2;
}
}
/* HTTP/1.0 默认关闭,除非显式 keep-alive */
if (is_http10 && !backend_wants_close) {
backend_wants_close = true;
}
/* 归还或关闭连接 */
if (backend_wants_close) {

93
main.c
View File

@ -103,74 +103,52 @@ static void print_usage(const char *prog) {
*/
static bool parse_args(int argc, char *argv[], cocoon_config_t *config) {
/* 默认值 */
config->root_dir = NULL;
memset(config, 0, sizeof(*config));
config->port = 8080;
config->threaded = false;
config->num_workers = 0;
config->max_connections = 0;
config->timeout_ms = 0;
config->log_level = LOG_LEVEL_INFO;
config->gzip_enabled = true;
config->brotli_enabled = true;
config->tls_cert = NULL;
config->tls_key = NULL;
config->tls_enabled = false;
config->access_log_path = NULL;
config->cors_enabled = false;
config->auth_user = NULL;
config->auth_pass = NULL;
config->rate_limit = 0;
config->num_plugins = 0;
bool has_root_dir = false;
bool has_port = false;
bool has_workers = false;
bool has_max_conn = false;
bool has_timeout = false;
bool has_log_level = false;
bool has_gzip_enabled = false;
bool has_brotli_enabled = false;
bool has_tls_cert = false;
bool has_tls_key = false;
bool has_tls_enabled = false;
bool has_access_log = false;
bool has_cors_enabled = false;
bool has_auth_user = false;
bool has_auth_pass = false;
bool has_rate_limit = false;
bool has_plugins = false;
/* 第一阶段:查找配置文件路径 */
const char *config_file = NULL;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-c") == 0) {
if (++i >= argc) return false;
config_file = argv[i];
}
}
/* 第二阶段:加载配置文件(如果有) */
if (config_file) {
if (!config_load_from_file(config_file, config)) {
fprintf(stderr, "Error: 无法加载配置文件: %s\n", config_file);
return false;
}
}
/* 第三阶段:解析命令行参数,覆盖配置文件 */
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-c") == 0) {
i++; /* 已处理,跳过 */
} else if (strcmp(argv[i], "-r") == 0) {
if (++i >= argc) return false;
free((void *)config->root_dir);
config->root_dir = strdup(argv[i]);
has_root_dir = true;
} else if (strcmp(argv[i], "-p") == 0) {
if (++i >= argc) return false;
config->port = (uint16_t)atoi(argv[i]);
if (config->port == 0) config->port = 8080;
has_port = true;
} else if (strcmp(argv[i], "-t") == 0) {
config->threaded = true;
} else if (strcmp(argv[i], "-w") == 0) {
if (++i >= argc) return false;
config->num_workers = (uint32_t)atoi(argv[i]);
has_workers = true;
} else if (strcmp(argv[i], "-m") == 0) {
if (++i >= argc) return false;
config->max_connections = (uint32_t)atoi(argv[i]);
has_max_conn = true;
} else if (strcmp(argv[i], "-o") == 0) {
if (++i >= argc) return false;
config->timeout_ms = (uint32_t)atoi(argv[i]);
has_timeout = true;
} else if (strcmp(argv[i], "-l") == 0) {
if (++i >= argc) return false;
if (strcmp(argv[i], "error") == 0) config->log_level = LOG_LEVEL_ERROR;
@ -181,51 +159,43 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) {
fprintf(stderr, "Unknown log level: %s\n", argv[i]);
return false;
}
has_log_level = true;
} else if (strcmp(argv[i], "-v") == 0) {
config->log_level = LOG_LEVEL_DEBUG;
has_log_level = true;
} else if (strcmp(argv[i], "--no-gzip") == 0) {
config->gzip_enabled = false;
has_gzip_enabled = true;
} else if (strcmp(argv[i], "--no-brotli") == 0) {
config->brotli_enabled = false;
has_brotli_enabled = true;
} else if (strcmp(argv[i], "--cert") == 0) {
if (++i >= argc) return false;
free((void *)config->tls_cert);
config->tls_cert = strdup(argv[i]);
has_tls_cert = true;
} else if (strcmp(argv[i], "--key") == 0) {
if (++i >= argc) return false;
free((void *)config->tls_key);
config->tls_key = strdup(argv[i]);
has_tls_key = true;
} 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;
free((void *)config->access_log_path);
config->access_log_path = strdup(argv[i]);
has_access_log = true;
} else if (strcmp(argv[i], "--cors") == 0) {
config->cors_enabled = true;
has_cors_enabled = true;
} else if (strcmp(argv[i], "--auth-user") == 0) {
if (++i >= argc) return false;
free((void *)config->auth_user);
config->auth_user = strdup(argv[i]);
has_auth_user = true;
} else if (strcmp(argv[i], "--auth-pass") == 0) {
if (++i >= argc) return false;
free((void *)config->auth_pass);
config->auth_pass = strdup(argv[i]);
has_auth_pass = true;
} else if (strcmp(argv[i], "--rate-limit") == 0) {
if (++i >= argc) return false;
config->rate_limit = (uint32_t)atoi(argv[i]);
has_rate_limit = true;
} else if (strcmp(argv[i], "--plugin") == 0) {
if (++i >= argc) return false;
if (config->num_plugins < COCOON_MAX_PLUGINS) {
config->plugins[config->num_plugins++] = strdup(argv[i]);
has_plugins = true;
} else {
fprintf(stderr, "Error: 最多支持 %d 个插件\n", COCOON_MAX_PLUGINS);
return false;
@ -239,23 +209,6 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) {
}
}
/* 如果有配置文件,先加载 */
if (config_file) {
if (!config_load_from_file(config_file, config)) {
fprintf(stderr, "Error: 无法加载配置文件: %s\n", config_file);
return false;
}
/* 用命令行参数覆盖配置文件 */
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_access_log,
has_cors_enabled, has_auth_user, has_auth_pass,
has_rate_limit,
has_plugins);
}
if (!config->root_dir) {
fprintf(stderr, "Error: 必须指定静态资源根目录(-r <dir> 或配置文件)\n");
return false;

11
proxy.c
View File

@ -546,6 +546,8 @@ static bool proxy_relay_backend(cocoon_socket_t client_fd, const http_request_t
/* 流式转发响应回客户端 */
bool success = false;
ssize_t total = 0;
bool connection_closed = false; /* 后端是否主动关闭连接 */
if (send_ok) {
char relay_buf[8192];
bool recv_ok = true;
@ -562,8 +564,11 @@ static bool proxy_relay_backend(cocoon_socket_t client_fd, const http_request_t
break;
}
if (r == 0) {
/* 后端关闭连接,如果尚未转发任何数据则视为失败 */
/* 后端关闭连接,如果尚未转发任何数据则视为失败
* connection_closed
*/
if (total == 0) recv_ok = false;
connection_closed = true;
break;
}
@ -585,8 +590,8 @@ static bool proxy_relay_backend(cocoon_socket_t client_fd, const http_request_t
if (total_forwarded) *total_forwarded = total;
/* 归还或关闭连接:只有后端未关闭时才归还 */
if (success && total > 0) {
/* 归还或关闭连接:只有后端未主动关闭时才归还 */
if (success && total > 0 && !connection_closed) {
proxy_pool_release(backend, backend_fd, tls_conn);
} else {
if (use_https && tls_conn) {

101
tests/http10_backend.py Normal file
View File

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
http10_backend.py HTTP/1.0 后端模拟器
模拟一个 HTTP/1.0 后端响应后发送 Connection: close 并立即关闭连接
用于验证反向代理连接池的 HTTP/1.0 兼容性
用法: python3 http10_backend.py <port> <directory>
"""
import sys
import socket
import os
import mimetypes
HTTP10_RESPONSE = """HTTP/1.0 200 OK
Connection: close
Content-Type: {ctype}
Content-Length: {length}
""".replace("\n", "\r\n")
HTTP10_NOT_FOUND = """HTTP/1.0 404 Not Found
Connection: close
Content-Type: text/plain
Content-Length: 13
404 Not Found
""".replace("\n", "\r\n")
def handle_client(conn, addr, doc_root):
try:
data = conn.recv(4096)
if not data:
return
# 解析请求行
lines = data.decode("utf-8", errors="replace").split("\r\n")
if not lines:
return
parts = lines[0].split()
if len(parts) < 2:
return
path = parts[1]
if path == "/":
path = "/index.html"
# 去掉路径中的前缀(如 /backend
if path.startswith("/backend"):
path = path[len("/backend"):]
if not path.startswith("/"):
path = "/" + path
# 安全路径
safe_path = os.path.normpath(os.path.join(doc_root, path.lstrip("/")))
print(f"[http10] doc_root={doc_root}, path={path}, safe_path={safe_path}", flush=True)
print(f"[http10] abspath={os.path.abspath(doc_root)}", flush=True)
print(f"[http10] exists={os.path.exists(safe_path)}, isfile={os.path.isfile(safe_path) if os.path.exists(safe_path) else False}", flush=True)
if not os.path.abspath(safe_path).startswith(os.path.abspath(doc_root)):
conn.sendall(HTTP10_NOT_FOUND.encode())
return
if os.path.exists(safe_path) and os.path.isfile(safe_path):
with open(safe_path, "rb") as f:
body = f.read()
ctype, _ = mimetypes.guess_type(safe_path)
if not ctype:
ctype = "application/octet-stream"
header = HTTP10_RESPONSE.format(ctype=ctype, length=len(body))
conn.sendall(header.encode() + body)
else:
conn.sendall(HTTP10_NOT_FOUND.encode())
except Exception as e:
print(f"[http10] 错误: {e}", file=sys.stderr)
finally:
conn.close()
def main():
if len(sys.argv) < 3:
print("用法: python3 http10_backend.py <port> <directory>", file=sys.stderr)
sys.exit(1)
port = int(sys.argv[1])
doc_root = sys.argv[2]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", port))
s.listen(5)
print(f"[http10] 启动于端口 {port}, 根目录: {doc_root}")
while True:
conn, addr = s.accept()
handle_client(conn, addr, doc_root)
if __name__ == "__main__":
main()

View File

@ -1159,6 +1159,68 @@ kill -9 $BACKEND_B_PID 2>/dev/null || true
kill_server
sleep 1
# === HTTP/1.0 后端兼容测试 ===
echo ""
echo "=== HTTP/1.0 后端兼容测试 ==="
# 启动 HTTP/1.0 后端(发送 Connection: close 后主动关闭)
python3 "$ROOT/../http10_backend.py" 9004 "$ROOT" > "$TMPDIR/backend_http10.log" 2>&1 &
BACKEND_HTTP10_PID=$!
sleep 1
# 创建带 HTTP/1.0 代理配置的配置文件
HTTP10_PROXY_CONFIG="$TMPDIR/http10_proxy_config.json"
cat > "$HTTP10_PROXY_CONFIG" << 'EOF'
{
"root_dir": "./tests/fixtures",
"port": 9999,
"log_level": "debug",
"proxies": [
{"prefix": "/backend", "target": "http://localhost:9004", "pool_size": 2}
]
}
EOF
$SERVER -c "$HTTP10_PROXY_CONFIG" > "$TMPDIR/server_http10_proxy.log" 2>&1 &
for i in {1..30}; do
if nc -z localhost 9999 2>/dev/null; then break; fi
sleep 0.1
done
# 第一次请求:应成功
http10_status1=$(curl -s -o /dev/null -w "%{http_code}" "http://$HOST/backend/index.html")
if [[ "$http10_status1" == "200" ]]; then
echo " ✓ HTTP/1.0 代理第一次请求 — HTTP 200"
pass
else
echo " ✗ HTTP/1.0 代理第一次请求 — 期望 200, 实际 $http10_status1"
fail
fi
# 第二次请求:验证代理能正确新建连接(旧连接已被后端关闭)
http10_status2=$(curl -s -o /dev/null -w "%{http_code}" "http://$HOST/backend/index.html")
if [[ "$http10_status2" == "200" ]]; then
echo " ✓ HTTP/1.0 代理第二次请求 — HTTP 200连接池正确关闭旧连接"
pass
else
echo " ✗ HTTP/1.0 代理第二次请求 — 期望 200, 实际 $http10_status2(连接池可能复用了已关闭的连接)"
fail
fi
# 检查代理日志中有关闭旧连接的日志
if grep -q "关闭" "$TMPDIR/server_http10_proxy.log" || grep -q "connection" "$TMPDIR/server_http10_proxy.log" || grep -q "新建" "$TMPDIR/server_http10_proxy.log"; then
echo " ✓ HTTP/1.0 代理日志 — 检测到连接管理日志"
pass
else
echo " ⊘ HTTP/1.0 代理日志 — 未检测到连接管理日志(日志级别可能不够)"
pass
fi
# 清理 HTTP/1.0 后端服务器
kill -9 $BACKEND_HTTP10_PID 2>/dev/null || true
kill_server
sleep 1
# === HTTPS 反向代理测试 ===
echo ""
echo "=== HTTPS 反向代理测试 ==="