55、HTTPS服务器:使用OpenSSL实现HTTPS服务器、证书加载

说实话,HTTP 明文传输在局域网里玩玩还行,真要上生产环境,那就是裸奔。我早年做过一个物联网网关项目,设备上报的数据里包含设备密钥,结果因为用了 HTTP,被中间人抓包抓了个精光……嗯,从那以后,凡是涉及敏感信息的通信,我必上 HTTPS。

这一讲,咱们就聊聊怎么用 OpenSSL 库,把一个普通的 TCP 服务器升级成 HTTPS 服务器。说白了,就是在 socket 层外面包一层 SSL/TLS 加密。

HTTPS 的本质是什么?

HTTPS 不是新协议,它就是 HTTP over SSL/TLS。你想想看,HTTP 是应用层,TCP 是传输层,SSL/TLS 夹在中间,负责加密和解密。

数据流向是这样的:

客户端 → SSL加密 → TCP传输 → SSL解密 → 服务器

所以,实现 HTTPS 服务器,核心就两步:

  1. 加载证书和私钥
  2. 用 SSL 库接管 socket 的读写

核心要点: OpenSSL 帮你处理了握手、密钥协商、加密解密这些脏活累活。你只需要在 accept() 之后,把普通的 fd 包装成 SSL 对象即可。

环境准备:装好 OpenSSL 开发库

在 Ubuntu 上,我习惯这样装:

sudo apt-get install libssl-dev

CentOS 的话:

sudo yum install openssl-devel

编译时记得链接 -lssl -lcrypto

gcc https_server.c -o https_server -lssl -lcrypto

小提示: 如果你用的是 OpenSSL 1.1.0 以上版本,注意 API 有些变化。我遇到过因为版本不匹配导致 SSL_CTX_new() 返回 NULL 的情况,查了半天才发现是库版本太老。

第一步:生成自签名证书

生产环境肯定要用 CA 签发的证书,但咱们做实验,自签名就够了。我一般用一行命令搞定:

openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes

这条命令会生成两个文件:

文件用途
server.key私钥,必须保密
server.crt证书,发给客户端

警告: 私钥文件权限一定要设置为 600,否则 OpenSSL 会报错。我曾经因为权限问题在线上排查了半小时,结果就是 chmod 600 server.key 的事。

第二步:初始化 SSL 上下文

这是整个流程的起点。我习惯把初始化逻辑封装成一个函数:

#include <openssl/ssl.h>
#include <openssl/err.h>

SSL_CTX* init_ssl_context() {
    // 初始化 SSL 库
    SSL_library_init();
    OpenSSL_add_all_algorithms();
    SSL_load_error_strings();

    // 创建 SSL 上下文,使用 TLS 服务端方法
    const SSL_METHOD *method = TLS_server_method();
    SSL_CTX *ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return NULL;
    }

    return ctx;
}

这里要注意,TLS_server_method() 是 OpenSSL 1.1.0 之后推荐的用法。老版本用的是 SSLv23_server_method(),但那个已经过时了。

第三步:加载证书和私钥

证书和私钥加载,我建议分开做,方便排查问题:

int load_certificates(SSL_CTX *ctx, const char *cert_file, const char *key_file) {
    // 加载证书
    if (SSL_CTX_use_certificate_file(ctx, cert_file, SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        return -1;
    }

    // 加载私钥
    if (SSL_CTX_use_PrivateKey_file(ctx, key_file, SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        return -1;
    }

    // 验证私钥和证书是否匹配
    if (!SSL_CTX_check_private_key(ctx)) {
        fprintf(stderr, "私钥和证书不匹配\n");
        return -1;
    }

    return 0;
}

为什么一定要调用 SSL_CTX_check_private_key? 我遇到过证书和私钥不匹配的情况,客户端握手时直接报错。这个检查能帮你提前发现问题,而不是等到线上才暴露。

第四步:创建 SSL 对象并握手

当 accept() 返回一个新的 socket fd 后,我们需要把它包装成 SSL 对象:

SSL* ssl = SSL_new(ctx);
SSL_set_fd(ssl, client_fd);

// 开始 TLS 握手
if (SSL_accept(ssl) <= 0) {
    ERR_print_errors_fp(stderr);
    SSL_free(ssl);
    close(client_fd);
    return;
}

// 握手成功,可以收发数据了
char buffer[1024];
int bytes = SSL_read(ssl, buffer, sizeof(buffer) - 1);
if (bytes > 0) {
    buffer[bytes] = '\0';
    printf("收到: %s\n", buffer);

    const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello HTTPS!";
    SSL_write(ssl, response, strlen(response));
}

// 关闭 SSL 连接
SSL_shutdown(ssl);
SSL_free(ssl);
close(client_fd);

你看,核心就是 SSL_accept() 代替了普通的 accept,SSL_read()/SSL_write() 代替了 read()/write()。其他逻辑几乎一模一样。

完整示例:一个极简 HTTPS 服务器

我把上面几段拼起来,就是一个能跑起来的 HTTPS 服务器了:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

#define PORT 4433

int main() {
    SSL_CTX *ctx = init_ssl_context();
    if (!ctx) return -1;

    if (load_certificates(ctx, "server.crt", "server.key") < 0) {
        SSL_CTX_free(ctx);
        return -1;
    }

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    printf("HTTPS 服务器启动,端口: %d\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);

        SSL *ssl = SSL_new(ctx);
        SSL_set_fd(ssl, client_fd);

        if (SSL_accept(ssl) <= 0) {
            ERR_print_errors_fp(stderr);
        } else {
            char buf[1024] = {0};
            SSL_read(ssl, buf, sizeof(buf));
            printf("收到请求:\n%s\n", buf);

            const char *resp = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, HTTPS!";
            SSL_write(ssl, resp, strlen(resp));
        }

        SSL_shutdown(ssl);
        SSL_free(ssl);
        close(client_fd);
    }

    close(server_fd);
    SSL_CTX_free(ctx);
    return 0;
}

用浏览器访问 https://localhost:4433,会提示证书不安全,因为咱们用的是自签名证书。点「继续访问」就能看到 "Hello, HTTPS!" 了。

核心流程图

下面这张图,把 HTTPS 服务器的启动和请求处理流程串起来了:

HTTPS 服务器核心流程 1. 初始化 SSL 库 2. 创建 SSL 上下文 3. 加载证书和私钥 4. socket → bind → listen 5. accept() 等待客户端连接 6. SSL_accept() 握手

避坑指南

我这些年踩过的坑,列几个典型的:

  • 证书路径问题: 程序启动时,当前工作目录可能不是你放证书的目录。我建议用绝对路径,或者把证书路径做成配置项。
  • SSL_accept 阻塞: 如果客户端连接后不发起握手,SSL_accept 会一直阻塞。生产环境一定要配合 select/poll/epoll 使用,或者设置超时。
  • 内存泄漏: 每个 SSL 对象都要记得 SSL_free,每个 SSL_CTX 都要 SSL_CTX_free。我见过线上服务跑了一周后 OOM,查下来就是 SSL 对象没释放。
  • 协议版本: 默认 TLS_server_method 支持 TLS 1.0 到 1.3。如果你只想用高版本,可以用 SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) 限制一下。

我的习惯: 在开发环境用自签名证书,在测试环境用 Let's Encrypt 的免费证书,生产环境才用商业 CA 签发的证书。这样分层,既省钱又安全。

好了,这一讲的内容就到这。HTTPS 服务器说白了就是给 socket 套了一层 SSL 壳,核心是证书加载和 SSL 对象管理。你只要把这两块搞明白,剩下的就是普通 socket 编程那套东西了。


公众号:蓝海资料掘金营,微信deep3321