在开发完毕部署过程中,SSL证书是避不开的一环,而且未来使用SSL证书的地方会越来越多。但是遍观互联网,大多数讲解SSL证书部署的文章都只是能用,却也只是当下能用,无法很好的扩展,也无法做到有效的管理。下面说说为什么

最主流的SSL证书签发管理工具是certbot和acme.sh,前者是letsencrypt官方出品的工具,后者是流行的第三方acme实现(优势是纯shell实现,安装容易)。

拿certbot举例, 网上的举例大多都是certbot --nginx 或者certbot certonly xxx . 确实,按照文章能正常生成证书,自动更新证书。但是这种方式可以说只顾眼前,不顾以后,拿certbot --nginx为例,你不需要有任何知识,按照提示一步步输入,理想情况下它能自动生成,自动管理证书,但是看看它存在的缺点

  1. 针对nginx的每一个config文件生成一个证书文件,时间一长,certbot给你申请着几十个证书也不是特别奇怪的事情。但是很多时候明明只是一个泛域名证书就能解决的事情,certbot硬是按照nginx配置文件申请了许多证书。遍观大型网站,它们全部都采取一个证书包含N个泛域名+N个单域名的模式,没有人会采用certbot --nginx这种操作,管理一个证书总比管理几十个证书要方便
  2. 上面说了泛域名比较好管理,但是泛域名要依靠dns api来获取,很多时候,Nginx会部署在N台机器上,每台机器都要放证书,那是否意味着每台机器都要各自使用dns api单独申请相同的泛域名证书,所以certbot的问题之二在于不管多台机器的证书同步问题
  3. 网上很多使用certbot certonly -d xx -d xx -d xx 使用此命令来申请多域名证书,假设后续需要增加或者删除域名列表,该如何操作呢

针对以上不方便的地方,我使用了如下方案

  1. 使用类似以下命令申请证书,单证书包含了多个泛域名+单域名,--cert-name命令类似于k-v的k,每次修改域名列表可以使用k来操作
1
2
3
4
5
6
7
8
9
10
11
12
certbot certonly \
--agree-tos \
--email demo@demo.com \
--dns-route53 \
--preferred-challenges=dns \
--cert-name demo.com \
-d "demo.com.cn" \
-d "*.demo.com.cn" \
-d "*.a.demo.com.cn" \
-d "*.b.demo.com.cn" \
-d "*.c.demo.com.cn" \
--deploy-hook "systemctl reload nginx"
  1. 对一个小型公司除特殊情况外,所有的域名都包含在内。使用额外的脚本,每天把证书的公钥和私钥打包加密,让它公网可访问,其他需要使用到证书的地方每天下载证书和已有的证书做对比,如果发现不一致则替换当前证书,并执行一条命令让依赖证书的服务重启

其它

如果你决定使用certbot,先读一遍certbot的官方文档 , 起码明白什么是AuthenticatorInstaller

安全性问题: 加密后的公钥私钥公网可访问,最差最差的情况是对方知道了你的密码,能得到证书,但是得到证书有什么大用吗? 我个人觉得这个风险是可控的

证书打包加密脚本如下, 需要以root权限运行,依赖age文件加密库和mholt/archiver打包

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
package main

import (
"bytes"
"context"
"crypto/tls"
"filippo.io/age"
"flag"
"fmt"
"github.com/mholt/archiver/v4"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/user"
"path"
"time"
)

var src = ""
var dst = ""
var post = ""
var passphrase = ""

func init() {
// 因为证书管理使用root, 所以该脚本只允许运行在root模式
currentUser, err := user.Current()
check(err)
if currentUser.Username != "root" {
panic("must be run with root user")
}
}

func check(e error) {
if e != nil {
panic(e)
}
}

func generateTar() *bytes.Buffer {
// 将fullchain.pem和privkey.pem文件打包成tar放到临时目录
fullchain := path.Join(src, "fullchain.pem")
if info, err := os.Lstat(fullchain); os.IsNotExist(err) || info == nil {
panic("fullchain not exists or can't readable")
} else {
isSymlink := info.Mode()&os.ModeSymlink == os.ModeSymlink
if isSymlink {
f, err := os.CreateTemp("/tmp", "ssl")
defer os.Remove(f.Name())
check(err)
input, err := os.Open(fullchain)
defer input.Close()
check(err)
io.Copy(f, input)
fullchain = f.Name()
}
}
privkey := path.Join(src, "privkey.pem")
if info, err := os.Lstat(privkey); os.IsNotExist(err) {
panic("privkey not exists or can't readable")
} else {
isSymlink := info.Mode()&os.ModeSymlink == os.ModeSymlink
if isSymlink {
f, err := os.CreateTemp("/tmp", "ssl")
defer os.Remove(f.Name())
check(err)
input, err := os.Open(privkey)
defer input.Close()
check(err)
io.Copy(f, input)
privkey = f.Name()
}
}
// 这个库没有实现对软链接的处理,所以遇到软链接先拷贝下
files, err := archiver.FilesFromDisk(nil, map[string]string{
fullchain: "fullchain.pem",
privkey: "privkey.pem",
})
check(err)
buf := bytes.NewBuffer(nil)
format := archiver.CompressedArchive{
Compression: archiver.Gz{},
Archival: archiver.Tar{},
}
err = format.Archive(context.Background(), buf, files)
return buf
}

func encryptFile(inputBuffer *bytes.Buffer) {
// 将tar目录加密
r, err := age.NewScryptRecipient(passphrase)
f, err := os.Create(dst)
check(err)

w, err := age.Encrypt(f, r)
check(err)
defer w.Close()
io.Copy(w, inputBuffer)
}

func fileCompare(src, dst string) bool {
if _, err := os.Stat(dst); os.IsNotExist(err) {
return false
}
f1, err := os.Open(src)
check(err)
defer f1.Close()

f2, err := os.Open(dst)
check(err)
defer f2.Close()

for {
b1 := make([]byte, 1024)
_, err1 := f1.Read(b1)

b2 := make([]byte, 1024)
_, err2 := f2.Read(b2)

if err1 != nil || err2 != nil {
if err1 == io.EOF && err2 == io.EOF {
return true
} else if err1 == io.EOF || err2 == io.EOF {
return false
} else {
log.Fatal(err1, err2)
}
}

if !bytes.Equal(b1, b2) {
return false
}
}
}

func moveFile(sourcePath, destPath string) {
inputFile, err := os.Open(sourcePath)
defer inputFile.Close()
check(err)

outputFile, err := os.Create(destPath)
defer outputFile.Close()
check(err)

_, err = io.Copy(outputFile, inputFile)
check(err)
}

func downloadFile() {
// 直接下载,替换src
temp, err := os.CreateTemp("/tmp", "ssl")
defer temp.Close()
check(err)
transCfg := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // ignore expired SSL certificates
}
client := http.Client{
Timeout: 10 * time.Second,
Transport: transCfg,
}
resp, err := client.Get(src)
defer resp.Body.Close()
check(err)
io.Copy(temp, resp.Body)
src = temp.Name()
}

func decryptFile() {
// 和目标地址进行对比,如果不一致则替换
f, err := os.Open(src)
check(err)
i, err := age.NewScryptIdentity(passphrase)
defer f.Close()
r, err := age.Decrypt(f, i)
check(err)
temp, err := os.CreateTemp("/tmp", "ssl")
defer os.Remove(temp.Name())
check(err)
_, err = io.Copy(temp, r)
check(err)
temp.Seek(0, io.SeekStart)
format := archiver.CompressedArchive{
Compression: archiver.Gz{},
Archival: archiver.Tar{},
}

tempfullchain, err := os.CreateTemp("/tmp", "ssl")
defer os.Remove(tempfullchain.Name())
check(err)
tempprivkey, err := os.CreateTemp("/tmp", "ssl")
defer os.Remove(tempprivkey.Name())
check(err)

handler := func(ctx context.Context, f archiver.File) error {
rc, err := f.Open()
check(err)
defer rc.Close()
if f.Name() == "fullchain.pem" {
io.Copy(tempfullchain, rc)
}
if f.Name() == "privkey.pem" {
io.Copy(tempprivkey, rc)
}
return nil
}
err = format.Extract(context.Background(), temp, nil, handler)
check(err)
if _, err := os.Stat(dst); os.IsNotExist(err) {
err = os.MkdirAll(dst, 0700)
check(err)
}
if (fileCompare(tempfullchain.Name(), path.Join(dst, "fullchain.pem")) == false) || (fileCompare(tempprivkey.Name(), path.Join(dst, "privkey.pem")) == false) {
moveFile(tempfullchain.Name(), path.Join(dst, "fullchain.pem"))
moveFile(tempprivkey.Name(), path.Join(dst, "privkey.pem"))
if len(post) > 0 {
cmd := exec.Command("sudo", "bash", "-c", post)
err = cmd.Run()
check(err)
}
}
}

func main() {
c := flag.Bool("c", false, "client role")
s := flag.Bool("s", false, "server role")
srcf := flag.String("src", "", "certificate source address")
dstf := flag.String("dst", "", "certificate storage destination")
passphrasef := flag.String("p", "", "encryption/decryption password")
postf := flag.String("post", "", "commands executed after certificate update")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "for server:\n")
fmt.Fprintf(os.Stderr, "sudo ./ssl_distribute -s -src /etc/letsencrypt/live/xx.com -dst file -p 123456\n")
fmt.Fprintf(os.Stderr, "for client:\n")
fmt.Fprintf(os.Stderr, "sudo ./ssl_distribute -c -src http://127.0.0.1:8000/file -dst . -p 123456 -post \"systemctl reload nginx\"\n")
flag.PrintDefaults()
}
flag.Parse()

src = *srcf
dst = *dstf
passphrase = *passphrasef
post = *postf
if *c {
downloadFile()
decryptFile()
defer os.Remove(src)
} else if *s {
buf := generateTar()
encryptFile(buf)
} else {
panic("must be client or server")
}
}