最近遇到一个 bug,调用 OpenSSL API 出错时,返回的错误信息牛头不对马嘴,不知道为什么会有这种情况。
仔细分析后发现,发生这种情况时之前都会有一次 OpenSSL API 出错,而且后一次调用出错返回的错误信息跟
前一次很相似。呃,难道 OpenSSL API 出错时,会返回多条错误信息?
搜索一番后发现,OpenSSL 出错时还真可能返回多条信息。根据这篇文档的说法,OpenSSL 使用了一个 thread local 的队列来存储错误内容。当 OpenSSL 调用出错时,它会往这个队列里面放置错误内容。由于 OpenSSL 内部调用相当复杂,时常会出现这种情况:
函数 A 调用了函数 B,之后在函数 B 里面发生错误,放入一个错误;函数 A 发现 函数 B 调用失败,
又记录一条错误信息。这时候错误队列里面就有两个错误。
这有点像我们做错误处理时,把同一个错误信息从底到顶多次记入日志。要命的是,OpenSSL 并不会主动清空这个
错误。如果在错误处理时只处理第一个错误,就会留下一个不干净的队列。下一次调用时如果出错,返回的就是上一次发生错误时遗留在队列的信息。注意 OpenSSL 用的是队列而不是栈来记录错误信息。如果 OpenSSL 用的是栈,那还好一点,毕竟拿出来的都是最新的错误信息。然而它用的是队列,那就不得不保证错误处理后整个队列是干净的。
之前我们做错误处理时,都是用类似于 ERR_get_xxx
这一类的函数取错误信息。所以需要封装一个能够打扫干净
整个错误队列的函数。在编写这个函数时,我基本照搬了 Nginx 的 ngx_ssl_error
,去掉或重新实现了其中 Nginx 特定的部分。新封装的函数如下:
static size_t
ssl_error(char *err, size_t max)
{
int flags;
const char *data;
char *p, *first, *last;
size_t n;
p = err;
first = err;
last = err + max;
for ( ;; ) {
n = ERR_peek_error_line_data(NULL, NULL, &data, &flags);
if (n == 0) {
break;
}
if (p != first && p < last) {
*p++ = ' ';
}
/* ERR_error_string_n() requires at least one byte */
if (p >= last - 1) {
goto next;
}
ERR_error_string_n(n, p, last - p);
while (p < last && *p) {
p++;
}
if (p < last && *data && (flags & ERR_TXT_STRING)) {
*p++ = ':';
while (p < last && *data) {
*p = *data;
p++;
data++;
}
}
next:
(void) ERR_get_error();
}
return p - first;
}
需要解释下 if (p < last && *data && (flags & ERR_TXT_STRING))
这个分支。在阅读了相关代码后,我发现 OpenSSL 调用出错时,除了会往队列里面放入个错误,有时还有额外附赠一个 err_data
。这个 err_data
的实际类型,由对应的 flags
参数确定。如果 flags
表示该 err_data
是一条文本,我们也需要把它当作
错误信息合并起来。
在处理完公司代码里面的问题后,我也顺便给我维护的 lua-resty-rsa 项目加上对应的 Lua 封装函数:
local function ssl_err()
local err_queue = {}
local i = 1
local data = ffi_new("const char*[1]")
local flags = ffi_new("int[1]")
while true do
local code = C.ERR_get_error_line_data(nil, nil, data, flags)
if code == 0 then
break
end
local err = C.ERR_reason_error_string(code)
if err ~= nil then
err_queue[i] = ffi_str(err)
i = i + 1
if data[0] ~= nil and band(flags[0], ERR_TXT_STRING) > 0 then
err_queue[i] = ffi_str(data[0])
i = i + 1
end
end
end
return nil, tab_concat(err_queue, ": ", 1, i - 1)
end
我们在调用 OpenSSL API 出错时,有时并不会用 OpenSSL 错误处理函数去获取错误信息,而是直接返回诸如 XXX failed
这样的自定义信息。如果 OpenSSL 跟传统的 Unix errno 一样,只是设置一个错误码,那么返回自己
定义的错误信息也无可厚非。可惜 OpenSSL 用了一个队列来存储错误,所以即使我们不关心实际的错误信息,我们
还是得调用下 ERR_clear_error()
把错误队列清空,不然下一个调用出错的人就会踩坑了。
我本来也想把这个问题也解决了,但是在琢磨了一会儿之后,我选择放弃。针对前面的 ERR_get_XXX
只处理第一条错误的问题,我可以在代码库里搜索用到这种写法的地方,通通替换成我封装之后的函数。但是对于返回自定义信息而没有清空错误队列的问题,没办法用搜索-替换的方式解决,只能去读每一个用到 OpenSSL 的函数。即使我能够把有问题的地方全部揪出来,改动面也太大了。毕竟错误处理的逻辑,相对而言会更加缺乏测试覆盖,就怕一不小心改出更诡异的 bug。
最后一个问题,既然我们没法确定调用 OpenSSL API 时,错误队列是否是干净的,那么出于防御性编程的目的,
是否应该在调用前主动用 ERR_clear_error()
?
我看了下 Nginx 的代码,貌似连以健壮出名的 Nginx 也没有小心谨慎到这种地步。当然根据 SSL_get_error
的文档,如果想调用这个函数获取 ssl handshake 时错误,就需要保证错误队列是空的,因为这个函数会调用ERR_peek_error()
获取队列里面第一个错误。据我了解,目前为止 Nginx 也只有在调用这个函数前才会清空
错误队列。
总结一下 OpenSSL 的错误处理:
- 如果要获取错误信息,需要处理整个错误队列,而不能像处理 errno 一样只获取一次错误信息。
- 注意错误里面可能会附带
ERR_TXT_STRING
类型的额外数据,这个也应该包含到错误信息中来。 - 如果不在意错误信息,一旦出错时也应该调用
ERR_clear_error()
,避免污染到后面的操作。 - 在调用
SSL_get_error
之前,记得清空错误队列。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。