-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
337 lines (321 loc) · 11.1 KB
/
main.go
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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
package main
import (
"encoding/json"
"flag"
"fmt"
"net"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/go-ini/ini"
)
const chRecName = "_acme-challenge"
func main() {
// Get command-line flags
cleanup := flag.Bool("cleanup", false, "Sets cleanup mode (to be used in --manual-cleanup-hook)")
verbose := flag.Bool("verbose", false, "Enables verbose output")
renewPath := flag.String("renew-path", "/etc/letsencrypt/renewal/", "Let's Encrypt renew folder path")
useRenewCreds := flag.Bool("use-renew-creds", true, "Try to read Cloudflare credentials from Let's Encrypt renew config?")
saveRenewCreds := flag.Bool("save-renew-creds", false, "Save Cloudflare credentials to Let's Encrypt renew config?")
onlySaveRenewCreds := flag.Bool("only-save-renew-creds", false, "Do nothing other than save Cloudflare credentials to Let's Encrypt renew config?")
flag.Parse()
if *onlySaveRenewCreds {
*saveRenewCreds = true
}
// Get environment variables
domain, ok := os.LookupEnv("CERTBOT_DOMAIN")
if !ok {
fmt.Println("[error] Environment variable CERTBOT_DOMAIN not set")
return
}
vt, ok := os.LookupEnv("CERTBOT_VALIDATION")
if !ok {
fmt.Println("[error] Environment variable CERTBOT_VALIDATION not set")
return
}
cfAPIAccessToken, ok := os.LookupEnv("CF_API_ACCESS_TOKEN")
if !ok && *verbose {
fmt.Println("[warning] Environment variable CF_API_ACCESS_TOKEN not set (this is OK if CF_API_EMAIL / CF_API_KEY is set or renew config contains auth)")
}
cfAPIEmail, ok := os.LookupEnv("CF_API_EMAIL")
if !ok && *verbose {
fmt.Println("[warning] Environment variable CF_API_EMAIL not set (this is OK if CF_API_ACCESS_TOKEN is set or renew config contains auth)")
}
cfAPIKey, ok := os.LookupEnv("CF_API_KEY")
if !ok && *verbose {
fmt.Println("[warning] Environment variable CF_API_KEY not set (this is OK if CF_API_ACCESS_TOKEN is set or renew config contains auth)")
}
// Get renewal file path
renewDomain := domain
var renewFilePath string
if *saveRenewCreds || (*useRenewCreds && (cfAPIEmail == "" || cfAPIKey == "")) {
for {
renewFilePath = path.Join(*renewPath, renewDomain+".conf")
if _, err := os.Stat(renewFilePath); err == nil {
break
}
tldPos := strings.LastIndexByte(renewDomain, '.')
sldPos := strings.IndexByte(renewDomain, '.')
if sldPos == tldPos || sldPos == -1 {
fmt.Println("[error] Certbot renewal file not found")
return
}
renewDomain = renewDomain[sldPos+1:]
}
}
// Load API email and/or key from renewal file
if *useRenewCreds && (cfAPIEmail == "" || cfAPIKey == "") {
file, err := ini.Load(renewFilePath)
if err != nil {
fmt.Printf("[error] Failed to load file \"%s\"\n%v\n", renewFilePath, err)
return
}
section := file.Section("go-certbot-cloudflare")
if section == nil {
fmt.Printf("[error] Could not find section \"go-certbot-cloudflare\" in file \"%s\"\n", renewFilePath)
return
}
if cfAPIAccessToken == "" {
keyAPIAccessToken := section.Key("cf_api_access_token")
if keyAPIAccessToken != nil {
cfAPIAccessToken = keyAPIAccessToken.String()
} else if *verbose {
fmt.Printf("[warning] Could not find key \"cf_api_access_token\" under section \"go-certbot-cloudflare\" in file \"%s\" (this is OK if cf_api_email / cf_api_key is set or environment variables provide auth)\n", renewFilePath)
}
}
if cfAPIEmail == "" {
keyAPIEmail := section.Key("cf_api_email")
if keyAPIEmail != nil {
cfAPIEmail = keyAPIEmail.String()
} else if *verbose {
fmt.Printf("[warning] Could not find key \"cf_api_email\" under section \"go-certbot-cloudflare\" in file \"%s\" (this is OK if cf_api_access_token is set or environment variables provide auth)\n", renewFilePath)
}
}
if cfAPIKey == "" {
keyAPIKey := section.Key("cf_api_key")
if keyAPIKey != nil {
cfAPIKey = keyAPIKey.String()
} else if *verbose {
fmt.Printf("[warning] Could not find key \"cf_api_key\" under section \"go-certbot-cloudflare\" in file \"%s\" (this is OK if cf_api_access_token is set or environment variables provide auth)\n", renewFilePath)
}
}
}
if cfAPIAccessToken == "" && (cfAPIEmail == "" || cfAPIKey == "") {
fmt.Println("[error] Cloudflare email or API key is empty")
return
}
// Get zone information from Cloudflare API
zonesRes := &cfListZonesResponse{}
zoneDomain := domain
for {
if *verbose {
fmt.Printf("[info] Looking up zone %s in Cloudflare account\n", zoneDomain)
}
httpRes, err := cfGet(cfAPIAccessToken, cfAPIEmail, cfAPIKey, "zones", url.Values{
"name": []string{zoneDomain},
"status": []string{"active"},
"page": []string{"1"},
"per_page": []string{"1"},
"match": []string{"all"},
})
if err != nil {
fmt.Printf("[error] Cloudflare request failed\n%v\n", err)
return
}
d := json.NewDecoder(httpRes.Body)
if err = d.Decode(zonesRes); err != nil {
fmt.Printf("[error] Failed to decode Cloudflare response\n%v\n", err)
return
}
if !zonesRes.Success {
fmt.Println("[error] Failed to look up zone")
for i := range zonesRes.Errors {
fmt.Println(zonesRes.Errors[i])
}
return
}
if len(zonesRes.Result) == 0 {
if *verbose {
fmt.Printf("[info] Zone \"%s\" not found in Cloudflare account, trying one subdomain less\n", zoneDomain)
}
tldPos := strings.LastIndexByte(zoneDomain, '.')
sldPos := strings.IndexByte(zoneDomain, '.')
if sldPos == tldPos || sldPos == -1 {
fmt.Println("[error] Zone not found in Cloudflare account")
return
}
zoneDomain = zoneDomain[sldPos+1:]
zonesRes = &cfListZonesResponse{}
continue
}
break
}
if len(zonesRes.Result[0].Nameservers) < 2 {
fmt.Println("[error] Could not find two or more nameservers in zone")
return
}
var subdomain string
if len(domain) > 2 && domain[:2] == "*." {
subdomain = chRecName + "." + domain[2:]
} else {
subdomain = chRecName + "." + domain
}
if *cleanup { // Cleanup mode
// Get _acme-challenge TXT records from Cloudflare API
if *verbose {
fmt.Println("[info] Looking up DNS ACME challenge records in Cloudflare zone")
}
httpRes, err := cfGet(cfAPIAccessToken, cfAPIEmail, cfAPIKey, "zones/"+zonesRes.Result[0].ID+"/dns_records", url.Values{
"type": []string{"TXT"},
"name": []string{subdomain},
"page": []string{"1"},
"per_page": []string{"100"},
"match": []string{"all"},
})
recordsRes := &cfListRecordsResponse{}
d := json.NewDecoder(httpRes.Body)
if err = d.Decode(recordsRes); err != nil {
fmt.Printf("[error] Failed to decode Cloudflare response\n%v\n", err)
return
}
if len(recordsRes.Result) == 0 {
if *verbose {
fmt.Println("[info] No challenge records to clean up")
}
return
}
// Delete all _acme-challenge TXT records with Cloudflare API
if *verbose {
fmt.Printf("[info] Found %d challenge record(s) to clean up\n", len(recordsRes.Result))
}
for i := range recordsRes.Result {
if *verbose {
fmt.Printf("[info] Deleting challenge record TXT %s: \"%s\"\n", recordsRes.Result[i].Name, recordsRes.Result[i].Content)
}
httpRes, err := cfDelete(cfAPIAccessToken, cfAPIEmail, cfAPIKey, "zones/"+zonesRes.Result[0].ID+"/dns_records/"+recordsRes.Result[i].ID, nil)
if err != nil {
fmt.Printf("[error] Cloudflare request failed\n%v\n", err)
return
}
deleteRes := &cfDeleteRecordResponse{}
d := json.NewDecoder(httpRes.Body)
if err = d.Decode(deleteRes); err != nil {
fmt.Printf("[error] Failed to decode Cloudflare response\n%v\n", err)
return
}
if !deleteRes.Success {
fmt.Println("[error] Failed to delete challenge record")
for i := range deleteRes.Errors {
fmt.Println(deleteRes.Errors[i])
}
return
}
}
} else if !*onlySaveRenewCreds { // Auth/normal mode
rs1 := resolver(net.JoinHostPort(zonesRes.Result[0].Nameservers[0], "53"))
rs2 := resolver(net.JoinHostPort(zonesRes.Result[0].Nameservers[1], "53"))
// Perform initial lookup of _acme-challenge TXT records using the Cloudflare DNS servers
if *verbose {
fmt.Printf("[info] Attempting initial lookup TXT %s\n", subdomain)
}
dnsRes, err := lookupCompareTXT(rs1, rs2, subdomain)
if err == nil && strSliceLookup(dnsRes, vt) {
if *verbose {
fmt.Println("[info] Expected challenge record already exists on domain")
}
return
}
// If initial lookup could not find records,
// create _acme-challenge TXT records using the Cloudflare API.
if *verbose {
fmt.Println("[info] Challenge record not found on domain")
fmt.Printf("[info] Creating TXT record %s with content \"%s\"\n", subdomain, vt)
}
httpRes, err := cfPostJSON(cfAPIAccessToken, cfAPIEmail, cfAPIKey, "zones/"+zonesRes.Result[0].ID+"/dns_records", &cfCreateDNSRecord{
Type: "TXT",
Name: subdomain,
Content: vt,
})
if err != nil {
fmt.Printf("[error] Cloudflare request failed\n%v\n", err)
return
}
createRes := &cfCreateRecordResponse{}
d := json.NewDecoder(httpRes.Body)
if err = d.Decode(createRes); err != nil {
fmt.Printf("[error] Failed to decode Cloudflare response\n%v\n", err)
return
}
if !createRes.Success {
fmt.Println("[error] Failed to create challenge record")
for i := range createRes.Errors {
fmt.Println(createRes.Errors[i])
}
return
}
// Wait for new _acme-challenge TXT record to update on Cloudflare nameservers
if *verbose {
fmt.Printf("[info] Attempting lookup TXT %s\n", subdomain)
}
dnsRes = nil
attempts := 0
for {
attempts++
if attempts > 30 {
fmt.Println("[error] Did not find expected challenge record, gave up after 30 attempts")
return
}
dnsRes, err = lookupCompareTXT(rs1, rs2, subdomain)
if err == errInconsistent {
if *verbose {
fmt.Println(err.Error())
}
time.Sleep(time.Second)
continue
} else if err != nil && !strings.Contains(err.Error(), "no such host") {
fmt.Printf("[warning] Failed lookup TXT %s: %v\n", subdomain, err)
time.Sleep(time.Second)
continue
}
if dnsRes == nil || len(dnsRes) == 0 || !strSliceLookup(dnsRes, vt) {
if *verbose {
fmt.Printf("[info] Challenge record \"%s\" missing from domain, retrying...\n", vt)
}
time.Sleep(time.Second)
continue
}
break
}
if *verbose {
fmt.Printf("[info] Found expected challenge record after %d attempt(s)\n", attempts)
}
}
// Save Cloudflare credentials to Let's Encrypt renew config
if *saveRenewCreds {
file, err := ini.Load(renewFilePath)
if err != nil {
fmt.Printf("[error] Failed to load file \"%s\"\n%v\n", renewFilePath, err)
return
}
file.DeleteSection("go-certbot-cloudflare")
section, err := file.NewSection("go-certbot-cloudflare")
if err != nil {
fmt.Println("[error] Failed to create section \"go-certbot-cloudflare\"")
return
}
if _, err = section.NewKey("cf_api_email", cfAPIEmail); err != nil {
fmt.Println("[error] Failed to create key \"cf_api_email\" in section \"go-certbot-cloudflare\"")
return
}
if _, err = section.NewKey("cf_api_key", cfAPIKey); err != nil {
fmt.Println("[error] Failed to create key \"cf_api_key\" in section \"go-certbot-cloudflare\"")
return
}
if err = file.SaveTo(renewFilePath); err != nil {
fmt.Printf("[error] Failed to save file \"%s\"\n", renewFilePath)
return
}
}
}