首页 ETCD 配置 TLS
文章
取消

ETCD 配置 TLS

前提知识: SSL/TLS 笔记

本文章演示如何在已有 ETCD 集群上同时配置开启 客户端与服务端之间(client-to-server)服务端与服务端之间(server-to-server/peer) 的 TLS。

建议在操作前先备份 data 目录和配置文件!

已有集群信息如下:

1
2
3
4
5
6
7
8
$ etcdctl member list -w table
+------------------+---------+-------+---------------------------+---------------------------+------------+
|        ID        | STATUS  | NAME  |        PEER ADDRS         |       CLIENT ADDRS        | IS LEARNER |
+------------------+---------+-------+---------------------------+---------------------------+------------+
| e36d8869dc221ffe | started | node1 | http://192.168.10.11:2380 | http://192.168.10.11:2379 |      false |
| 243fcfa74ec0736a | started | node2 | http://192.168.10.12:2380 | http://192.168.10.12:2379 |      false |
| c8ad351a3ef67e9e | started | node3 | http://192.168.10.13:2380 | http://192.168.10.13:2379 |      false |
+------------------+---------+-------+---------------------------+---------------------------+------------+

生成证书

使用 python 生成

下面示例中使用的 python 脚本 gen_etcd_certs.py 源码在文章末尾附上。

生成 根证书和私钥

1
2
3
$ python gen_etcd_certs.py -c gen_root_cert -s ./certs 
Saved: ./certs/root.key
Saved: ./certs/root.cert

生成 客户端证书和私钥

1
2
3
$ python gen_etcd_certs.py -c gen_client_cert -s ./certs -k certs/root.key -t certs/root.cert
Saved: ./certs/client.key
Saved: ./certs/client.cert

生成 节点证书和私钥

1
2
3
4
5
6
7
8
9
10
11
$ python gen_etcd_certs.py -c gen_server_cert -s ./certs -k certs/root.key -t certs/root.cert -i 192.168.10.11
Saved: ./certs/192.168.10.11.key
Saved: ./certs/192.168.10.11.cert

$ python gen_etcd_certs.py -c gen_server_cert -s ./certs -k certs/root.key -t certs/root.cert -i 192.168.10.12
Saved: ./certs/192.168.10.12.key
Saved: ./certs/192.168.10.12.cert

$ python gen_etcd_certs.py -c gen_server_cert -s ./certs -k certs/root.key -t certs/root.cert -i 192.168.10.13
Saved: ./certs/192.168.10.13.key
Saved: ./certs/192.168.10.13.cert

使用 openssl 生成

生成 根证书和私钥

1
2
3
4
5
6
7
8
9
10
11
12
$ openssl genrsa -out root.key 2048
Generating RSA private key, 2048 bit long modulus
.............................+++
........................................+++
e is 65537 (0x10001)

$ openssl req -new -sha256 -key root.key -out root.csr -subj "/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC/CN=CA"

$ openssl x509 -req -days 3650 -sha256 -signkey root.key -in root.csr -out root.cert
Signature ok
subject=/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC/CN=CA
Getting Private key

生成 客户端证书和私钥

1
2
3
4
5
6
7
8
9
10
11
12
$ openssl genrsa -out client.key 2048
Generating RSA private key, 2048 bit long modulus
.........+++
......+++
e is 65537 (0x10001)

$ openssl req -new -sha256 -key client.key  -out client.csr -subj "/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC"

$ openssl x509 -req -days 3650 -sha256 -CA  root.cert -CAkey root.key  -CAserial root.srl  -CAcreateserial -in client.csr -out client.cert
Signature ok
subject=/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC
Getting CA Private Key

生成 节点证书和私钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$ openssl genrsa -out 192.168.10.11.key 2048
Generating RSA private key, 2048 bit long modulus
.......................+++
..................................+++
e is 65537 (0x10001)
$ openssl req -new -sha256 -key 192.168.10.11.key -out 192.168.10.11.csr -subj "/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC"
$ echo "subjectAltName = @names\n[names]\nIP.1 = 127.0.0.1\nIP.2 = 192.168.10.11" > 192.168.10.11.ext
$ openssl x509 -req -days 3650 -sha256 -CA  root.cert -CAkey root.key  -CAserial root.srl  -CAcreateserial -in 192.168.10.11.csr -out 192.168.10.11.cert -extfile 192.168.10.11.ext
Signature ok
subject=/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC
Getting CA Private Key

$ openssl genrsa -out 192.168.10.12.key 2048
Generating RSA private key, 2048 bit long modulus
.......................+++
..................................+++
e is 65537 (0x10001)
$ openssl req -new -sha256 -key 192.168.10.12.key -out 192.168.10.12.csr -subj "/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC"
$ echo "subjectAltName = @names\n[names]\nIP.1 = 127.0.0.1\nIP.2 = 192.168.10.12" > 192.168.10.12.ext
$ openssl x509 -req -days 3650 -sha256 -CA  root.cert -CAkey root.key  -CAserial root.srl  -CAcreateserial -in 192.168.10.12.csr -out 192.168.10.12.cert -extfile 192.168.10.12.ext
Signature ok
subject=/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC
Getting CA Private Key

$ openssl genrsa -out 192.168.10.13.key 2048
Generating RSA private key, 2048 bit long modulus
.......................+++
..................................+++
e is 65537 (0x10001)
$ openssl req -new -sha256 -key 192.168.10.13.key -out 192.168.10.13.csr -subj "/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC"
$ echo "subjectAltName = @names\n[names]\nIP.1 = 127.0.0.1\nIP.2 = 192.168.10.13" > 192.168.10.13.ext
$ openssl x509 -req -days 3650 -sha256 -CA  root.cert -CAkey root.key  -CAserial root.srl  -CAcreateserial -in 192.168.10.13.csr -out 192.168.10.13.cert -extfile 192.168.10.13.ext
Signature ok
subject=/C=CN/ST=CQ/L=YB/O=BC/OU=ZWC
Getting CA Private Key

上传证书

节点需要上传的文件
192.168.10.11root.cert, root.key, client.cert, client.key, 192.168.10.11.cert, 192.168.10.11.key
192.168.10.12root.cert, root.key, client.cert, client.key, 192.168.10.12.cert, 192.168.10.12.key
192.168.10.13root.cert, root.key, client.cert, client.key, 192.168.10.13.cert, 192.168.10.13.key

更新配置

节点 192.168.10.11 配置文件需要更新的配置:

listen-peer-urls: https://0.0.0.0:2380
listen-client-urls: https://0.0.0.0:2379
advertise-client-urls: https://192.168.10.11:2379
client-transport-security:
  # 服务端证书,在 TLS 握手过程中提供给客户端。
  cert-file: /path/to/192.168.10.11.cert
  # 服务端私钥,用来解密客户端使用服务端公钥加密发送的数据。
  key-file: /path/to/192.168.10.11.key
  # 是否要求客户端访问时提供客户端证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证客户端证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false
peer-transport-security:
  # 本节点证书,在 TLS 握手过程中提供给伙伴节点。
  cert-file: /path/to/192.168.10.11.cert
  # 本节点私钥,用来解密伙伴节点使用本节点公钥加密发送的数据。
  key-file: /path/to/192.168.10.11.key
  # 是否要求伙伴节点访问时提供其证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证伙伴节点证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false

节点 192.168.10.12 配置文件需要更新的配置:

listen-peer-urls: https://0.0.0.0:2380
listen-client-urls: https://0.0.0.0:2379
advertise-client-urls: https://192.168.10.12:2379
client-transport-security:
  # 服务端证书,在 TLS 握手过程中提供给客户端。
  cert-file: /path/to/192.168.10.12.cert
  # 服务端私钥,用来解密客户端使用服务端公钥加密发送的数据。
  key-file: /path/to/192.168.10.12.key
  # 是否要求客户端访问时提供客户端证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证客户端证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false
peer-transport-security:
  # 本节点证书,在 TLS 握手过程中提供给伙伴节点。
  cert-file: /path/to/192.168.10.12.cert
  # 本节点私钥,用来解密伙伴节点使用本节点公钥加密发送的数据。
  key-file: /path/to/192.168.10.12.key
  # 是否要求伙伴节点访问时提供其证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证伙伴节点证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false

节点 192.168.10.13 配置文件需要更新的配置:

listen-peer-urls: https://0.0.0.0:2380
listen-client-urls: https://0.0.0.0:2379
advertise-client-urls: https://192.168.10.13:2379
client-transport-security:
  # 服务端证书,在 TLS 握手过程中提供给客户端。
  cert-file: /path/to/192.168.10.13.cert
  # 服务端私钥,用来解密客户端使用服务端公钥加密发送的数据。
  key-file: /path/to/192.168.10.13.key
  # 是否要求客户端访问时提供客户端证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证客户端证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false
peer-transport-security:
  # 本节点证书,在 TLS 握手过程中提供给伙伴节点。
  cert-file: /path/to/192.168.10.13.cert
  # 本节点私钥,用来解密伙伴节点使用本节点公钥加密发送的数据。
  key-file: /path/to/192.168.10.13.key
  # 是否要求伙伴节点访问时提供其证书。
  client-cert-auth: true
  # 受信任的 CA 证书(root 证书/根证书),用来验证伙伴节点证书。
  trusted-ca-file: /path/to/root.cert
  # 是否自动配置 TLS,开启该配置后则无需上面的其他配置。
  auto-tls: false

使用 etcdclt 命令更新所有节点的 peer-urlshttps(更新时保证所有节点都在线,如果有其他节点是不在线的,更新后这些节点需要作为新节点重新加入):

1
2
3
4
5
6
7
8
$ etcdctl member update e36d8869dc221ffe --peer-urls="https://192.168.10.11:2380"
Member e36d8869dc221ffe updated in cluster 77feb499f2ffa1c8

$ etcdctl member update 243fcfa74ec0736a --peer-urls="https://192.168.10.12:2380"
Member 243fcfa74ec0736a updated in cluster 77feb499f2ffa1c8

$ etcdctl member update c8ad351a3ef67e9e --peer-urls="https://192.168.10.13:2380"
Member c8ad351a3ef67e9e updated in cluster 77feb499f2ffa1c8

重启集群

先依次停止所有节点服务,然后再依次启动所有节点服务(在启动第一个节点时会一直等待第二个节点的加入,这时可以直接去启动第二个节点让其加入后,第一个节点也就启动成功了,接着继续去启动第三个节点即可),启动完成后重新查看集群信息如下:

1
2
3
4
5
6
7
8
$ etcdctl --key="/path/to/client.key" --cert="/path/to/client.cert" --cacert="/path/to/root.cert" member list -w table
+------------------+---------+-------+----------------------------+----------------------------+------------+
|        ID        | STATUS  | NAME  |        PEER ADDRS          |       CLIENT ADDRS         | IS LEARNER |
+------------------+---------+-------+----------------------------+----------------------------+------------+
| e36d8869dc221ffe | started | node1 | https://192.168.10.11:2380 | https://192.168.10.11:2379 |      false |
| 243fcfa74ec0736a | started | node2 | https://192.168.10.12:2380 | https://192.168.10.12:2379 |      false |
| c8ad351a3ef67e9e | started | node3 | https://192.168.10.13:2380 | https://192.168.10.13:2379 |      false |
+------------------+---------+-------+----------------------------+----------------------------+------------+

TLS 与鉴权

TLS 与鉴权(Authentication)是两个不同的功能,互不影响,不能混为一谈,已知的相互间有关联的情况只有一种:当鉴权和 client-transport-security 都开启的情况下,一个客户端使用设置了 CN 的证书 client.cert 访问服务端,且未提供用户密码时,CN 的值则被当作用户名进行鉴权,但是如果访问时提供了用户密码,则使用提供的用户进行鉴权。

gen_etcd_certs.py 源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""
ETCD 证书生成脚本。

@requirements: 
    cryptography; python_version >= '3.6'
@author: zhaowcheng@163.com
@changelog:
    2025-01-25(v0.1.0): 初版
"""

import os
import sys
import argparse
import datetime
import ipaddress

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import (Encoding, 
                                                          PrivateFormat,
                                                          NoEncryption,
                                                          load_pem_private_key)
from cryptography.x509 import (Name, 
                               NameAttribute, 
                               SubjectAlternativeName,
                               CertificateBuilder, 
                               Certificate,
                               BasicConstraints,
                               IPAddress,
                               load_pem_x509_certificate)
from cryptography.x509.oid import NameOID
from cryptography.x509 import random_serial_number


VERSION = '0.1.0'
NAMEATTRS = [
    NameAttribute(NameOID.COUNTRY_NAME, "CN"),
    NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "CQ"),
    NameAttribute(NameOID.LOCALITY_NAME, "YB"),
    NameAttribute(NameOID.ORGANIZATION_NAME, "BC"),
    NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "ZWC")
]


def printerr_and_exit(msg: str, rc: int = 1) -> None:
    """
    打印错误消息并退出。

    :param msg: 消息
    :param rc: 退出码。
    """
    print(f'ERROR: {msg}', file=sys.stderr)
    exit(rc)


def save_key(key: rsa.RSAPrivateKey, path: str) -> None:
    """
    保存私钥。

    :param key: 私钥。
    :param path: 保存路径。
    """
    with open(path, 'wb') as f:
        f.write(key.private_bytes(
            encoding=Encoding.PEM,
            format=PrivateFormat.TraditionalOpenSSL,
            encryption_algorithm=NoEncryption()
        ))
    print(f'Saved: {path}')


def save_cert(cert: Certificate, path: str) -> None:
    """
    保存证书。

    :param cert: 证书。
    :param path: 保存路径。
    """
    with open(path, 'wb') as f:
        f.write(cert.public_bytes(Encoding.PEM))
    print(f'Saved: {path}')


def gen_root_cert(savedir: str, days: int) -> None:
    """
    生成根证书。

    :param savedir: 保存目录。
    :param days: 有效期(天)。
    """
    # 生成根私钥
    root_private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048
    )

    # 生成根证书的公钥
    root_public_key = root_private_key.public_key()

    # 生成根证书的主题
    subject = issuer = Name(NAMEATTRS + [NameAttribute(NameOID.COMMON_NAME, "CA")])

    # 生成根证书
    root_cert = CertificateBuilder(
    ).subject_name(
        subject
    ).issuer_name(
        issuer
    ).public_key(
        root_public_key
    ).serial_number(
        random_serial_number()
    ).not_valid_before(
        datetime.datetime.now(datetime.timezone.utc)
    ).not_valid_after(
        datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days)
    ).add_extension(
        BasicConstraints(ca=True, path_length=None), critical=True
    ).sign(root_private_key, hashes.SHA256())

    # 保存
    save_key(root_private_key, os.path.join(savedir, 'root.key'))
    save_cert(root_cert, os.path.join(savedir, 'root.cert'))

def gen_client_cert(
    rootkey: str, 
    rootcert: str, 
    savedir: str, 
    days: int, 
    cn: str = ''
) -> None:
    """
    生成客户端证书。

    :param rootkey: 根私钥。
    :param rootcert: 根证书。
    :param savedir: 保存目录。
    :param days: 有效期(天)。
    :param cn: 用户名。
    """
    # 加载根私钥和证书
    with open(rootkey, 'rb') as f:
        root_private_key = load_pem_private_key(f.read(), None)
    with open(rootcert, 'rb') as f:
        root_cert = load_pem_x509_certificate(f.read())

    # 生成客户端私钥
    client_private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048
    )

    # 生成客户端证书的公钥
    client_public_key = client_private_key.public_key()

    # 生成客户端证书的主题
    if cn:
        subject = Name(NAMEATTRS + [NameAttribute(NameOID.COMMON_NAME, cn)])
    else:
        subject = Name(NAMEATTRS)

    # 生成客户端证书
    client_cert = CertificateBuilder().subject_name(
        subject
    ).issuer_name(
        root_cert.subject
    ).public_key(
        client_public_key
    ).serial_number(
        random_serial_number()
    ).not_valid_before(
        datetime.datetime.now(datetime.timezone.utc)
    ).not_valid_after(
        datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days)
    ).sign(root_private_key, hashes.SHA256())

    # 保存
    save_key(client_private_key, os.path.join(savedir, 'client.key'))
    save_cert(client_cert, os.path.join(savedir, 'client.cert'))


def gen_server_cert(
    rootkey: str, 
    rootcert: str, 
    savedir: str, 
    days: int,
    ip: str
) -> None:
    """
    生成服务端证书。

    :param rootkey: 根私钥。
    :param rootcert: 根证书。
    :param savedir: 保存目录。
    :param days: 有效期(天)。
    :param ip: 服务端 ip。
    """
    # 加载根私钥和证书
    with open(rootkey, 'rb') as f:
        root_private_key = load_pem_private_key(f.read(), None)
    with open(rootcert, 'rb') as f:
        root_cert = load_pem_x509_certificate(f.read())

    # 生成服务器私钥
    server_private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048
    )

    # 生成服务器证书的公钥
    server_public_key = server_private_key.public_key()

    # 生成服务器证书的主题
    subject = Name(NAMEATTRS)

    # 生成服务器证书的扩展(包括 IP SAN)
    san = SubjectAlternativeName([
        IPAddress(ipaddress.IPv4Address('127.0.0.1')),
        IPAddress(ipaddress.IPv4Address(ip))
    ])

    # 生成服务器证书
    server_cert = CertificateBuilder().subject_name(
        subject
    ).issuer_name(
        root_cert.subject
    ).public_key(
        server_public_key
    ).serial_number(
        random_serial_number()
    ).not_valid_before(
        datetime.datetime.now(datetime.timezone.utc)
    ).not_valid_after(
        datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days)
    ).add_extension(
        san, critical=False
    ).sign(root_private_key, hashes.SHA256())

    # 保存
    save_key(server_private_key, os.path.join(savedir, f'{ip}.key'))
    save_cert(server_cert, os.path.join(savedir, f'{ip}.cert'))


def create_parser() -> argparse.ArgumentParser:
    """
    创建命令行参数解析器。
    """
    parser = argparse.ArgumentParser()
    parser.add_argument('-c', '--command', required=True, choices=['gen_root_cert', 'gen_client_cert', 'gen_server_cert'])
    parser.add_argument('-s', '--savedir', required=True, default='.', help='Save directory. [default: .]')
    parser.add_argument('-d', '--days', type=int,  default=3650, 
                        help='Certificate validity period. [default: 3650]')
    parser.add_argument('-n', '--cn', help='Common name(only used for gen_client_cert).')
    parser.add_argument('-k', '--root-key', required=('gen_client_cert' in sys.argv or 'gen_server_cert' in sys.argv),
                        help='Root private key.')
    parser.add_argument('-t', '--root-cert', required=('gen_client_cert' in sys.argv or 'gen_server_cert' in sys.argv),
                        help='Root certificate.')
    parser.add_argument('-i', '--ip', required=('gen_server_cert' in sys.argv),
                        help='Server IP.')
    parser.add_argument('-v', '--version', action='version', version=VERSION)
    return parser


def main() -> None:
    """
    入口函数。
    """
    parser = create_parser()
    args = parser.parse_args()
    if not os.path.exists(args.savedir):
        printerr_and_exit(f'Savedir `{args.savedir}` does not exist.')
    if args.command == 'gen_root_cert':
        gen_root_cert(args.savedir, args.days)
    elif args.command == 'gen_client_cert':
        gen_client_cert(args.root_key, args.root_cert, args.savedir, args.days, args.cn)
    elif args.command == 'gen_server_cert':
        gen_server_cert(args.root_key, args.root_cert, args.savedir, args.days, args.ip)


if __name__ == '__main__':
    main()

参考资料

  • [Transport security model] : https://etcd.io/docs/v3.5/op-guide/security/
本文由作者按照 CC BY 4.0 进行授权

SSL/TLS 笔记

使用 iptables 为 KVM 虚拟机实现桥接网络