MySQL via Stunnel and more

先说一说用Stunnel来保护MySQL连接的目的。用作开发和测试环境的MySQL数据库服务器,若选择直接装在本机,任何时候都永远连接localhost:3306,安全可靠得很呢。现在已是2017年,云服务选择不要太多,点几下分分钟就可以在AWS或Azure上起一个MySQL实例。而且,如果开发和测试环境的MySQL能让多位开发者一起使用,遇到问题一起重现和调试,何乐而不为呢。对服务器而言,做一些备份/恢复,同步,主/从切换等试验,有专门的数据库服务可操作也是极好的。

数据虽是开发调试用,也不要随便泄漏,所以决定用SSL保护通信,选择了最常见的Stunnel来完成。我直接在Azure上启了一个最轻量的MySQL实例,安装Stunnel,打开远程端口,然后就开始让本地MySQL客户端和代码如果通过SSL连上的踩坑之旅。

MySQL(或者现在叫MariaDB了吧)安装后,如何启动建用户啥的就不多说了。其实MySQL自带SSL加密功能,但是在了解一些情况后,还是决定通过Stunnel套一个SSL加密层。一是因为有人分析过MySQL自带SSL实现性能太差,二是通用SSL加密层可以在其他如Redis之类的服务上重用,三是产品MySQL服务器会是无SSL,没必要在测试开发服务器上引入这个差异。但看到MySQL客户端和NodeJS的mysql npm都支持SSL,以为都是标准协议实现可以通用,哪知道这就是第一个坑。

装好Stunnel,简单的在/etc/stunnel/建个stunnel.conf,或者直接把随安装包的stunnel.conf-sample拷贝过来做基础就可以动手开始为MySQL做一个加密通信层了。

一个基本stunnel.conf如下:

setuid = stunnel  
setgid = stunnel  
pid=/var/run/stunnel/stunnel.pid  
;Un-comment the 2 lines below if diagnosis needed
;output = /var/log/stunnel/stunnel.log
;debug = 7
verify = 1  
; 1: Verify the certificate if present.
;       - if no certificate presented from remote, accept the connection
;       - if a certificate presented from remote, then
;               * if it is valid, log and continue the connection
;               * if it is not, drop the connection
; 2: Require and verify certificate. If no or invalid certificate presented, drop the connection.
; 3: Require and verify certificate against locally installed certificate.

请记得为这个服务创建账号和组stunnel。示例配置文件是nobody/nobody,但个人喜欢专款专用。

sudo useradd -M -U -d /dev/null -s /sbin/nologin stunnel  
  • -M 不建立该账号的用户目录。这个账号又不是真实会登录的用户,要用户目录何用。
  • -U 建立同账号名用户组。
  • -d 指明该账号的用户目录。虽然-M已指明不建立,但是默认值会是/home/stunnel,为避免带来混淆和疑惑,给一个/dev/null说明此账号没有用户目录。
  • -s 指明该账号的登录shell就是没shell。一个道理,这是个不会有真实用户登录的账号。

同时保证目录/var/run/stunnel存在并且账号stunnel有权限读写。

sudo mkdir /var/run/stunnel -p  
sudo chown stunnel:stunnel /var/run/stunnel  

同理,如果要开始记录和查看log,记得保证目录/var/log/stunnel存在并且账号stunnel可读写。

verify = 1注释有写明白解释,就是哪怕客户端没有提供证书也允许连接。最终目标虽是只允许持有认可证书的客户端连接,这样MySQL开发用账号的密码都可以不要,简单方便。但第一步嘛,先允许任何连接,等调通了再把证书加上,也就说最后会是verify = 3。多嘴一句,不会有verify = 2的原因是客户端会用自己的证书和签名认证,没有官方CA签名认证的,所以2会必然失败。

接下来就简单了,设置一个端口作MySQL的加密通信向外端口就好。这里选33060作接收端口,直接连接本机的MySQL端口3306。若是MySQL服务器设置于内网其他服务器,直接上ip:port格式就好,比如10.0.0.42:3306

[mysql]
cert = /etc/letsencrypt/live/[my website domain name]/fullchain.pem  
key = /etc/letsencrypt/live/[my website domain name]/privkey.pem  
accept = 33060  
connect = 3306  

可以启动一下stunnel试试,虽然什么都还没有,但也不应该有报错的。

你没看错,我直接拿从LetsEncrypt给我web站点申请的证书和私钥做MySQL SSL信道的证书和私钥。理由简单直接,因为这个加密认证保护是确保客户端可信任,而非服务器端。虽然我也可以自签名证书作服务器端的证书,但是所有客户端必须多作一步设置信任自签名证书。多这一步虽不麻烦,但是既然服务器上已有CA签名的证书,也和web服务器同一个域名,直接拿来为所有客户端省却麻烦岂不更好。

好了,因为服务器端的verify = 1,我们现在就可以从客户端试着链接。开始踩入第一个坑,就是直接拿mysql命令,mysql客户端Toad(免费软件但功能很全很强大)和NodeJS代码链接SSL,统统不成功!

mysql命令带上参数--ssl就可以试图链接SSL加密的服务端。此处不成功应不是证书设置问题,因为服务器端的设置既启用了CA认证的证书验明自己,又对客户端证书不作要求来者不拒。用Toad和NodeJS代码连接,遇到同样的问题。Toad全GUI,设置非常直观就不多说了。NodeJS代码直接用mysql写个简单的连接测试。

(() => {
  'use strict';

  const mysql = require('mysql');

  let conn = mysql.createConnection({
    host: '[my website domain name]',
    port: 33060,
    user: 'root',
    password: '[Try guess my root password]',
    ssl: { }
  });

  conn.connect((err) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log('Connected!');
  });
}).call(this);

按照mysql npm文档关于SSL options的说法,也就是TLS.createSecureContect(options),key/cert/ca这三个都是可选参数而已,就是说哪怕就传个{},那也是开启SSL链接。但不敢百分百确认这个假设,就拿OpenSSL给自己生成了私钥和自签名证书。如果假设不对,虽然服务器端verify = 1会因证书验证失败而断掉链接,但那至少证明代码有效链接成功设置没问题,我们接下来解决证书问题就好。

openssl req -new -x509 -key mysqlclient.key -out mysqlclient.crt -days 3650  

然后NodeJS代码的ssl参数加上了,但连接同样不成功。

    ssl: {
      key: fs.readFileSync('mysqlclient.key'),
      cert: fs.readFileSync('mysqlclient.crt')
    }

查看服务器端log文件,会看到最后一行是

Service [mysql] accepted connection from [client ip: port]  

然后永远卡死。

记得stunnel.conf里有一句;debug = 7不?删掉分号,重现启动stunnel,再连接一试。更丰富的调试信息会让我们看到log最后一句这次是:

SSL state (accept): before/accept initialization  

好吧,实际上此处踩中坑后我还花了各种办法不断尝试,比如把证书那一套都建立起来了,但仍然不成功。后来分析认为是mysql客户端(包括mysql npm代码的SSL实现)不是标准的,至少和stunnel不通用,不如拿其他SSL客户端给mysql客户端做个bridge试试。虽然Windows上也可以下一个Stunnel作客户端用,不过写几句NodeJS代码更快些。

(() => {
  'use strict';

  const net = require('net');
  const tls = require('tls');

  const host = 'localhost';
  const port = 3306;

  let server = net.createServer((conn) => {
    console.log('Client connected from %s:%d', conn.remoteAddress, conn.remotePort);
    let socket = tls.connect({
      host: '[my website domain name]',
      port: 33060
    }, () => {
      console.log('Connected to %s:%d succeeded', socket.remoteAddress, socket.remotePort);
      conn.pipe(socket);
      socket.pipe(conn);
    });

    socket.on('error', (err) => {
      console.error(err);
      socket.end();
    });

    socket.on('end', () => {
      console.log('Remote server disconnected.');
    });

    conn.on('end', () => {
      console.log('Client disconnected.');
    });
  });

  server.listen(port, host, () => {
    console.log('Server listening on %s:%d', host, port);
  });

  server.on('error', (err) => {
    switch (err.code) {
      case 'EACCES':
        console.log('Failed to bind %s:%d. Check if it is bound by others.', host, port);
        break;
      default:
        console.error(err);
    }
    server.close();
  });
}).call(this);

然后直接拿mysql客户端连localhost:3306,一连就通。看来果然是mysql对SSL连接的实现与标准不通用嘛。好了,这个坑算踩过去了。

接下来生成客户端证书再作个自认证。实话实说,每次干这种活计,还是免不了要搜网上的教程。其实全靠这么一个openssl,参数也就那么几个,但不得不吐槽openssl命令行设计非常的不直观,而且不少命令可以因参数不同而功能完全不同。还有就是网上五花八门的教程对生成文件后缀名不统一,各取各的,时而.pem时而又.key,当打开多个教程页面互相参考时不得不反复看长长的命令来确认其目的,很难直观的从生成文件看出来。其实.pem是文件格式(Privacy Enhanced Mail)而已,私钥/公钥/证书都可以是这个格式,与这个格式对应的应该是DER/P7B/PFX这些格式。本着让命名表达意图,我的文件后缀名把这个多余的格式说明给抛弃了,直接就是私钥 -> .key,证书 -> .crt,证书签名申请 -> .csr。

再多嘴一句,一般随openssl安装包都会有个CA.sh或CA.pl之类的便捷脚本,大部分操作都可以直接用这个脚本带简单参数完成。个人猜测她们出现的原因也是我前面吐槽的,openssl的命令行设计非常不直观,所以就有封装好的脚本让用户直接使用。若是你自己非常清楚自己在做什么,用CA.sh直接完成操作其实还是很便捷的。

要做自认证,第一步是给服务器做一个自签名的证书。 你可以

openssl genrsa -out priv.key 4096  
openssl req -new -x509 -days 3650 -key priv.key -out ca.crt  

也可以一条命令完成生成私钥和证书

openssl req -new -x509 -newkey rsa:4096 -days 3650 -nodes -keyout priv.key -out ca.crt  

看到了没,默认genrsa命令生成私钥是不作DES加密的,但是放到req命令里来同时生成私钥确默认加密,需带上参数-nodes才与第一条命令效果一致。这些细节设计就是容易让用户导致混杂不清的地方。

也可以用前面提到的CA脚本

DAYS='-days 3650' /etc/pki/tls/misc/CA -newcert  

证书有效期的天数通过环境变量传入,但默认密钥长度2048无法改动了。生成的文件名也是固定的newkey.pemnewcert.pem。而且密钥是有DES加密的,你需要指定加密密码(pass phrase不清楚确定翻译,但明白DES加密原理应该知道这是来做什么的)。命令短了很多,但可控制的东西也少了,各取所需吧。

第二步是生成客户端密钥和证书申请。可以这么做

openssl genrsa -out client.key 4096  
openssl req -new -days 3650 -key client.key -out client.csr  

看出这两句和第一步的头两句差别在哪里麽?就是少了个-x509

同理,少输入一个-x509也可以一步完成

openssl req -new -newkey rsa:4096 -days 3650 -nodes -out client.csr -keyout client.key  

x509是证书的标准格式的一个名字而已,带此参数生成证书可以算勉强理解吧,不带就生成证书申请却怎么理解呢?此处先忍住不吐槽。

当然也可以用那个缩短敲键盘时间和省略无聊参数的CA脚本啦

DAYS='-days 3650' /etc/pki/tls/misc/CA -newreq-nodes  

感动不?这次又可以指定不要DES加密密钥。当然默认密钥长度还是2048,而且最后只生成一个把密钥和证书申请都放在一起的newreq.pem。你要是不幸用了CA -newreq而非CA -newreq-nodes你又会得到加密的密钥在newkey.pem而证书申请在newreq.pem。反正吧,本着尽量绕晕用户的设计思路,请务必小心选择命令。

其实提交证书申请根本不需要带密钥,而且密钥是永远需要妥善保护的,哪怕我这种自签名证书认证环境我也需要保护密钥不外泄,毕竟最后密钥是保证连接客户端可信任的机制,怎么能在要一起提交请求签名的文件里反而不带DES加密保护的密钥呢。经历太多的我是无从吐槽了,有时候真希望我的理解都是错的,会灵光咋现忽然明白这些命令设计背后的思路其实有她更深层的道理。如果真如此,请一定留言告诉我。

第三步,拿第一步做出来的自签名证书和密钥给第二步的证书申请签名,结果将是由自签名证书签名的证书。对不起,我的文字有把你绕晕麽?其实就是第一步那一对文件我们将绝对信任,她们将在服务器端验证其他证书可信任否;而第二步生成了私钥和证书申请,将申请被纳入第一步那一对文件的认证体系;第三步这个签名就是将证书申请签名完成证书,纳入其认证体系。希望我解释清楚了,但背后原理与细节还是请参考相关书籍和资料吧。

给证书申请签名,完成证书

openssl x509 -req -in client.csr -CA ca.crt -CAkey priv.key -set_serial 42 -out client.crt  

什么?!x509又出现了?你没看错,这次她不是参数而是命令。

用CA脚本来完成

sudo /etc/pki/tls/misc/CA -sign  

此命令默认参数是newreq.pem,生成文件是newcert.pem。如果不出意外,你直接用CA -sign是会出错的。因为openssl x509真的只是签名,而CA -sign是签名之后还要更新一系列簿记信息纳入其认证体系,在被请求验证时就会根据其簿记信息查询。这一套簿记信息的目录和文件若没有建立起来,直接CA -sign是会报错的。解决很简单

sudo /etc/pki/tls/misc/CA -newca  

这一步除了在系统目录构建若干文件夹,还会为本机生成一个密钥和自签名证书作根证书并存入,就是第一步那个操作这条命令可以代劳并直接把结果放入该放的地方。细心如你,当然会注意到-newca-sign都是在root下完成的。

其实呢,对应sudo CA -sign的openssl命令应该是

sudo openssl ca -policy policy_anything -in client.csr -out client.crt  

这一条命令除了签名,还会把被签名的证书信息去更新系统簿记信息。

因为openssl x509真的只是签名把csr给做成crt,没有系统簿记信息可查询和更新,也就是需要多带个-set_serial指定个序列号。那直接拿openssl x509签名而不完成簿记有什么意义呢?有的,就是当你只需要通过指定个别文件就完成认证体系的设置,而非依赖与操作系统上的根证书及其默认认证体系。这就和我踩到的第二个炕相关,stunnel.conf里的CAfileCApath设置。

其实是我大胆的假设了如果对stunnel.conf不做CAfileCApath的设置,她会自动读取openssl生成根证书及其他簿记信息的系统目录完成认证。结果非但不是如此,而且问题愈加复杂化了。不光需要手动指定目录,有用户和文件权限设置的更改,而且不同Linux发行版上这个CA脚本对目录位置的处理还不一样:Ubuntu是./demoCA,明显知道你要做自签名认证,所以不动/etc/为好;CoreOS却是/etc/pki/CA,直接扔到了/etc/下。前面那个假设和后面发现的复杂性,让我在CAfileCApath的设置上花了不少时间各种尝试。最后发现,Ubuntu的设计思路,就是./demoCA把生成文件都限制到个本地目录是极好的,你做根证书是自签名,不带任何官方CA光环,直接扔去/etc下让整个系统来都用上有什么意义呢,我在这台服务器上为别的服务做认证一定要用到同一个根证书麽?沿着Ubuntu把自签名认证体系限制到一个目录下的思路,我直接更简化了

cat ca.crt client.crt >> stunnel.crts  

把stunnel.crts放入和stunnel.conf同一个目录,权限全都只开给用户stunnel就好,然后在stunnel.conf中直接

verify = 3  
CAfile = stunnel.crts  

重启stunnel,把client.keyclient.crt扔到需要连接的客户端上,就是那个NodeJS写的简单的SSL bridge啦,把密钥和证书参数给指向这两个文件。运行,连接,成功。然后拿Toad连接localhost:3306成功后,建了个不要密码的开发用mysql账户,因为有证书认证,不要密码就好。再安利一下,Toad很好用很强大。

若要加入新的客户端,重复前面第二和第三步,然后把新生成的.crt文件内容加入到stunnel.crts就是把新的客户端证书纳入认证体系。取消一个客户端就是直接把那个客户端内容从stunnel.crts中给删掉,就是这样。其实这些操作就是简化了openssl命令在其簿记目录和文件中的操作和stunnel对所谓根证书和相关簿记信息的依赖。也许哪里不对,也许有缺陷,只是目前工作得还不错,欢迎指出,谢谢!

wingc

Read more posts by this author.