-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathclient.go
More file actions
668 lines (596 loc) · 19.7 KB
/
Copy pathclient.go
File metadata and controls
668 lines (596 loc) · 19.7 KB
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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
package allinkl
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/clbanning/mxj"
"github.com/libdns/libdns"
"github.com/tiaguinho/gosoap"
)
// ApiBase is the URL for the KAS API WSDL.
const ApiBase = "https://kasapi.kasserver.com/soap/wsdl/KasApi.wsdl"
// TimeoutTime is the default timeout for API requests.
const TimeoutTime = 15000 * time.Millisecond
// ChachedRecords stores cached DNS records for zones to minimize API calls.
var ChachedRecords = make(map[string][]allinklRecord)
// kasCallMu serializes all KAS SOAP calls across goroutines.
//
// The KAS API enforces a minimum delay between requests ("flood
// protection") and this package tracks that delay via
// waitForFloodDelay/updateFloodDelay. Without a mutex, concurrent callers
// (e.g. Caddy solving several DNS-01 challenges for a SAN certificate, or
// two ACME issuers racing at once) can each pass the delay check at nearly
// the same instant and then both call KAS simultaneously, tripping the
// flood limit. KAS's rejection in that case does not always come back as a
// plain <Fault> element (see findFaultString below), which previously
// surfaced as an unhelpful "invalid response format" error. Holding this
// lock around each full request/response round trip guarantees calls are
// fully serialized and the flood delay is honored.
var kasCallMu sync.Mutex
// findFaultString searches a decoded SOAP response for a fault message,
// regardless of XML namespace prefix.
//
// mxj preserves namespace prefixes as part of map keys, so a real SOAP
// fault can come back as "Fault", "soap:Fault", "SOAP-ENV:Fault", etc.
// The previous fault-detection only ever checked the bare "Fault" key, so
// namespaced faults (and other unexpected shapes) were missed entirely and
// fell through to a generic "invalid response format" error that hid the
// actual reason (auth failure, unknown zone, rate limiting, ...). This walks
// the whole decoded structure looking for any key containing "faultstring"
// and returns its text.
func findFaultString(v interface{}) (string, bool) {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if strings.Contains(strings.ToLower(k), "faultstring") {
if s, ok := extractText(val); ok && s != "" {
return s, true
}
}
}
for _, val := range t {
if s, ok := findFaultString(val); ok {
return s, true
}
}
case []interface{}:
for _, item := range t {
if s, ok := findFaultString(item); ok {
return s, true
}
}
}
return "", false
}
// extractText pulls a plain string out of either a raw string value or an
// mxj "{#text: ...}" wrapper map.
func extractText(v interface{}) (string, bool) {
switch t := v.(type) {
case string:
return t, true
case map[string]interface{}:
if s, ok := t["#text"].(string); ok {
return s, true
}
}
return "", false
}
// GetAllRecords retrieves all DNS records for the specified zone from the KAS API.
func (p *Provider) GetAllRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
httpClient := &http.Client{
Timeout: TimeoutTime,
}
soap, err := gosoap.SoapClient(ApiBase, httpClient)
if err != nil {
fmt.Println("Error creating SOAP client:", err)
}
// Prepare parameters like PHP script
params := map[string]interface{}{
"zone_host": strings.TrimSuffix(zone, ".") + ".",
}
requestData := map[string]interface{}{
"kas_login": p.KasUsername,
"kas_auth_type": "plain",
"kas_auth_data": p.KasPassword,
"kas_action": "get_dns_settings",
"KasRequestParams": params,
}
// JSON encode the entire request like PHP
jsonData, err := json.Marshal(requestData)
if err != nil {
log.Fatalf("Error encoding JSON: %v", err)
}
kasCallMu.Lock()
defer kasCallMu.Unlock()
p.waitForFloodDelay()
// Call the SOAP method with JSON-encoded params
res, err := soap.Call("KasApi", gosoap.Params{
"Params": string(jsonData),
})
if err != nil {
log.Fatalf("Error calling SOAP method: %v", err)
}
if res == nil {
log.Fatal("Response is nil")
}
mv, err := mxj.NewMapXml([]byte(res.Body))
if err != nil {
log.Fatalf("Error converting XML to map: %v", err)
}
records := []interface{}{}
// Defensive navigation through the nested map
root, ok := mv["KasApiResponse"].(map[string]interface{})
if ok {
ret, ok := root["return"].(map[string]interface{})
if ok {
items := ret["item"]
var itemList []interface{}
switch v := items.(type) {
case []interface{}:
itemList = v
case map[string]interface{}:
itemList = []interface{}{v}
}
for _, item := range itemList {
mitem, _ := item.(map[string]interface{})
keyMap, _ := mitem["key"].(map[string]interface{})
key, _ := keyMap["#text"].(string)
if key == "Response" {
val, _ := mitem["value"].(map[string]interface{})
respItems := val["item"]
var respList []interface{}
switch v := respItems.(type) {
case []interface{}:
respList = v
case map[string]interface{}:
respList = []interface{}{v}
}
for _, respItem := range respList {
respMap, _ := respItem.(map[string]interface{})
rkeyMap, _ := respMap["key"].(map[string]interface{})
rkey, _ := rkeyMap["#text"].(string)
if rkey == "ReturnInfo" {
rval, _ := respMap["value"].(map[string]interface{})
arr := rval["item"]
switch v := arr.(type) {
case []interface{}:
records = v
case map[string]interface{}:
records = []interface{}{v}
}
}
}
}
}
}
}
// Initialize recordsList with enough elements
recordsList := make([]libdns.Record, len(records))
rawRecords := make([]allinklRecord, len(records))
for i, rec := range records {
recMap, _ := rec.(map[string]interface{})
items := recMap["item"]
var kvList []interface{}
switch v := items.(type) {
case []interface{}:
kvList = v
case map[string]interface{}:
kvList = []interface{}{v}
}
// Create a temporary record for this entry
currentRecord := allinklRecord{}
for _, kv := range kvList {
kvMap, _ := kv.(map[string]interface{})
keyMap, _ := kvMap["key"].(map[string]interface{})
key, _ := keyMap["#text"].(string)
valueMap, valueIsMap := kvMap["value"].(map[string]interface{})
var value interface{}
if valueIsMap {
value, _ = valueMap["#text"]
} else {
value = kvMap["value"]
}
// Set fields in the temporary Record struct
switch key {
case "record_id":
if strVal, ok := value.(string); ok {
currentRecord.ID = strVal
}
case "record_zone":
if strVal, ok := value.(string); ok {
currentRecord.ZoneID = strVal
}
case "record_type":
if strVal, ok := value.(string); ok {
currentRecord.Type = strVal
}
case "record_name":
if strVal, ok := value.(string); ok {
currentRecord.Name = strVal
} else if value == nil {
// For apex/root domain records, name might be nil
currentRecord.Name = "@"
}
case "record_data":
if strVal, ok := value.(string); ok {
currentRecord.Value = strVal
}
case "record_ttl":
if ttlVal, ok := value.(float64); ok {
currentRecord.TTL = int(ttlVal)
} else if ttlStr, ok := value.(string); ok {
// Try to parse string to int if needed
var ttl int
fmt.Sscanf(ttlStr, "%d", &ttl)
currentRecord.TTL = ttl
}
}
}
rawRecords[i] = currentRecord
var libdnsRecord, err = currentRecord.toLibdnsRecord(zone)
if err != nil {
log.Printf("Error converting record to libdns format: %v", err)
continue
}
recordsList[i] = libdnsRecord
}
ChachedRecords[zone] = rawRecords
return recordsList, nil
}
// AppendRecord adds a single DNS record to the specified zone.
func (p *Provider) AppendRecord(ctx context.Context, zone string, record libdns.Record) ([]libdns.Record, error) {
httpClient := &http.Client{
Timeout: TimeoutTime,
}
soap, err := gosoap.SoapClient(ApiBase, httpClient)
if err != nil {
return nil, fmt.Errorf("error creating SOAP client: %w", err)
}
// Convert the record to RR to access its fields
rr := record.RR()
if rr.TTL/time.Second < 600 {
rr.TTL = 600 * time.Second
}
// record_aux is for MX priority only — use 0 for all other record types
aux := 0
if rr.Type == "MX" {
aux = int(rr.TTL / time.Second)
}
// Prepare parameters like PHP script
params := map[string]interface{}{
"record_name": rr.Name,
"record_type": rr.Type,
"record_data": rr.Data,
"record_aux": aux,
"record_ttl": int(rr.TTL.Seconds()),
"zone_host": strings.TrimSuffix(zone, ".") + ".",
}
requestData := map[string]interface{}{
"kas_login": p.KasUsername,
"kas_auth_type": "plain",
"kas_auth_data": p.KasPassword,
"kas_action": "add_dns_settings",
"KasRequestParams": params,
}
// JSON encode the entire request like PHP
jsonData, err := json.Marshal(requestData)
if err != nil {
return nil, fmt.Errorf("error encoding JSON: %w", err)
}
// Respect KAS flood delay dynamically. Hold kasCallMu for the entire
// wait+call round trip so concurrent Append/Set/Delete/Get calls can't
// race past the delay check and flood the API simultaneously.
kasCallMu.Lock()
defer kasCallMu.Unlock()
p.waitForFloodDelay()
// Call the SOAP method with JSON-encoded params
res, err := soap.Call("KasApi", gosoap.Params{
"Params": string(jsonData),
})
if err != nil {
return nil, fmt.Errorf("error calling SOAP method: %w", err)
}
if res == nil {
return nil, fmt.Errorf("response is nil")
}
mv, err := mxj.NewMapXml([]byte(res.Body))
if err != nil {
return nil, fmt.Errorf("error converting XML to map: %w; raw response: %s", err, res.Body)
}
// Check for a SOAP Fault (e.g. record_already_exists, auth errors, flood
// errors) anywhere in the response, regardless of namespace prefix.
if faultStr, ok := findFaultString(mv); ok {
return nil, fmt.Errorf("KAS API error: %s", faultStr)
}
// Parse response to check for success and get record ID
root, ok := mv["KasApiResponse"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing KasApiResponse element; raw response: %s", res.Body)
}
ret, ok := root["return"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing return element; raw response: %s", res.Body)
}
// Check for errors in response
items := ret["item"]
var itemList []interface{}
switch v := items.(type) {
case []interface{}:
itemList = v
case map[string]interface{}:
itemList = []interface{}{v}
}
for _, item := range itemList {
mitem, _ := item.(map[string]interface{})
keyMap, _ := mitem["key"].(map[string]interface{})
key, _ := keyMap["#text"].(string)
if key == "Response" {
val, _ := mitem["value"].(map[string]interface{})
// Update flood delay from response
p.updateFloodDelay(itemList)
// Extract the new record ID from ReturnInfo
if recordID, exists := val["ReturnInfo"]; exists {
if idStr, ok := recordID.(string); ok {
rr := record.RR()
ChachedRecords[zone] = append(ChachedRecords[zone], allinklRecord{
ID: idStr,
Name: rr.Name,
Type: rr.Type,
Value: rr.Data,
ZoneID: strings.TrimSuffix(zone, "."),
TTL: int(rr.TTL.Seconds()),
})
}
}
}
}
// Return the record with any updates from the server
// Since the API doesn't return the new record details, we return the original
return []libdns.Record{record}, nil
}
// getRecordByName looks up a cached record by name and type. This is used by
// SetRecord, where the value of the record is the thing being changed and so
// cannot be used as part of the lookup key.
//
// Note that if a zone legitimately contains multiple records with the same
// name and type (e.g. several TXT records used for ACME challenges), this
// lookup is inherently ambiguous and may return any one of them. Callers
// that need to uniquely identify a specific record (e.g. to delete it)
// should use getRecordByNameTypeValue instead.
func (p *Provider) getRecordByName(ctx context.Context, zone string, record libdns.Record, recursive bool) (allinklRecord, error) {
rr := record.RR()
for _, crecord := range ChachedRecords[zone] {
if crecord.Name == rr.Name && crecord.Type == rr.Type {
return crecord, nil
}
}
if !recursive {
p.GetAllRecords(ctx, zone)
return p.getRecordByName(ctx, zone, record, true)
}
return allinklRecord{}, fmt.Errorf("record not found: name=%s type=%s", rr.Name, rr.Type)
}
// getRecordByNameTypeValue looks up a cached record by name, type, AND value.
// Matching on all three fields is required to uniquely identify a record
// when multiple records share the same name and type — for example, two TXT
// records created for parallel ACME "_acme-challenge" validations. Matching
// on name alone (as before) could pick the wrong record and delete/target
// the one that is still needed, breaking ACME validation.
func (p *Provider) getRecordByNameTypeValue(ctx context.Context, zone string, record libdns.Record, recursive bool) (allinklRecord, error) {
rr := record.RR()
for _, crecord := range ChachedRecords[zone] {
if crecord.Name == rr.Name && crecord.Type == rr.Type && crecord.Value == rr.Data {
return crecord, nil
}
}
if !recursive {
p.GetAllRecords(ctx, zone)
return p.getRecordByNameTypeValue(ctx, zone, record, true)
}
return allinklRecord{}, fmt.Errorf("record not found: name=%s type=%s data=%s", rr.Name, rr.Type, rr.Data)
}
// SetRecord updates a single DNS record in the specified zone.
func (p *Provider) SetRecord(ctx context.Context, zone string, record libdns.Record) ([]libdns.Record, error) {
searchedRecord, err := p.getRecordByName(ctx, zone, record, false)
if err != nil {
return nil, fmt.Errorf("record not found: %s", record.RR().Name)
}
httpClient := &http.Client{
Timeout: TimeoutTime,
}
soap, err := gosoap.SoapClient(ApiBase, httpClient)
if err != nil {
return nil, fmt.Errorf("error creating SOAP client: %w", err)
}
rr := record.RR()
if rr.TTL/time.Second < 600 {
rr.TTL = 600 * time.Second
}
ttlInSeconds := int(rr.TTL / time.Second)
params := map[string]interface{}{
"record_name": rr.Name,
"record_type": rr.Type,
"record_data": rr.Data,
"record_aux": ttlInSeconds,
"record_id": searchedRecord.ID,
}
requestData := map[string]interface{}{
"kas_login": p.KasUsername,
"kas_auth_type": "plain",
"kas_auth_data": p.KasPassword,
"kas_action": "update_dns_settings",
"KasRequestParams": params,
}
// JSON encode the entire request like PHP
jsonData, err := json.Marshal(requestData)
if err != nil {
return nil, fmt.Errorf("error encoding JSON: %w", err)
}
kasCallMu.Lock()
defer kasCallMu.Unlock()
p.waitForFloodDelay()
// Call the SOAP method with JSON-encoded params
res, err := soap.Call("KasApi", gosoap.Params{
"Params": string(jsonData),
})
if err != nil {
return nil, fmt.Errorf("error calling SOAP method: %w", err)
}
if res == nil {
return nil, fmt.Errorf("response is nil")
}
mv, err := mxj.NewMapXml([]byte(res.Body))
if err != nil {
return nil, fmt.Errorf("error converting XML to map: %w; raw response: %s", err, res.Body)
}
// Check for a SOAP Fault anywhere in the response, regardless of
// namespace prefix.
if faultStr, ok := findFaultString(mv); ok {
return nil, fmt.Errorf("KAS API error: %s", faultStr)
}
// Parse response to check for success
root, ok := mv["KasApiResponse"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing KasApiResponse element; raw response: %s", res.Body)
}
ret, ok := root["return"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing return element; raw response: %s", res.Body)
}
// Check for errors in response
items := ret["item"]
var itemList []interface{}
switch v := items.(type) {
case []interface{}:
itemList = v
case map[string]interface{}:
itemList = []interface{}{v}
}
for _, item := range itemList {
mitem, _ := item.(map[string]interface{})
keyMap, _ := mitem["key"].(map[string]interface{})
key, _ := keyMap["#text"].(string)
if key == "Response" {
p.updateFloodDelay(itemList)
}
}
// If we reach here, the record was successfully updated.
// Update the cache so subsequent lookups (e.g. a follow-up Delete) see
// the new value rather than the stale one.
for i, crecord := range ChachedRecords[zone] {
if crecord.ID == searchedRecord.ID {
ChachedRecords[zone][i].Type = rr.Type
ChachedRecords[zone][i].Value = rr.Data
ChachedRecords[zone][i].TTL = ttlInSeconds
break
}
}
updatedRecord := libdns.RR{
Type: record.RR().Type,
Name: record.RR().Name,
Data: record.RR().Data,
TTL: record.RR().TTL,
}
return []libdns.Record{updatedRecord}, nil
}
// DeleteRecord removes a single DNS record from the specified zone.
func (p *Provider) DeleteRecord(ctx context.Context, zone string, record libdns.Record) ([]libdns.Record, error) {
// Match on name, type, AND value so that when multiple records share the
// same name (e.g. two TXT records for parallel ACME challenges), the
// correct one is deleted instead of an arbitrary match.
searchedRecord, err := p.getRecordByNameTypeValue(ctx, zone, record, false)
if err != nil {
rr := record.RR()
return nil, fmt.Errorf("record not found: name=%s type=%s data=%s", rr.Name, rr.Type, rr.Data)
}
httpClient := &http.Client{
Timeout: TimeoutTime,
}
soap, err := gosoap.SoapClient(ApiBase, httpClient)
if err != nil {
return nil, fmt.Errorf("error creating SOAP client: %w", err)
}
params := map[string]interface{}{
"record_id": searchedRecord.ID,
}
requestData := map[string]interface{}{
"kas_login": p.KasUsername,
"kas_auth_type": "plain",
"kas_auth_data": p.KasPassword,
"kas_action": "delete_dns_settings",
"KasRequestParams": params,
}
// JSON encode the entire request like PHP
jsonData, err := json.Marshal(requestData)
if err != nil {
return nil, fmt.Errorf("error encoding JSON: %w", err)
}
kasCallMu.Lock()
defer kasCallMu.Unlock()
p.waitForFloodDelay()
// Call the SOAP method with JSON-encoded params
res, err := soap.Call("KasApi", gosoap.Params{
"Params": string(jsonData),
})
if err != nil {
return nil, fmt.Errorf("error calling SOAP method: %w", err)
}
if res == nil {
return nil, fmt.Errorf("response is nil")
}
mv, err := mxj.NewMapXml([]byte(res.Body))
if err != nil {
return nil, fmt.Errorf("error converting XML to map: %w; raw response: %s", err, res.Body)
}
// Check for a SOAP Fault anywhere in the response, regardless of
// namespace prefix.
if faultStr, ok := findFaultString(mv); ok {
return nil, fmt.Errorf("KAS API error: %s", faultStr)
}
// Parse response to check for success
root, ok := mv["KasApiResponse"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing KasApiResponse element; raw response: %s", res.Body)
}
ret, ok := root["return"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid response format: missing return element; raw response: %s", res.Body)
}
// Check for errors in response
items := ret["item"]
var itemList []interface{}
switch v := items.(type) {
case []interface{}:
itemList = v
case map[string]interface{}:
itemList = []interface{}{v}
}
for _, item := range itemList {
mitem, _ := item.(map[string]interface{})
keyMap, _ := mitem["key"].(map[string]interface{})
key, _ := keyMap["#text"].(string)
if key == "Response" {
p.updateFloodDelay(itemList)
}
}
// If we reach here, the record was successfully deleted
deletedRecord := libdns.RR{
Type: record.RR().Type,
Name: record.RR().Name,
Data: record.RR().Data,
TTL: record.RR().TTL,
}
// Remove the record from the cache
for i, crecord := range ChachedRecords[zone] {
if crecord.ID == searchedRecord.ID {
ChachedRecords[zone] = append(ChachedRecords[zone][:i], ChachedRecords[zone][i+1:]...)
break
}
}
return []libdns.Record{deletedRecord}, nil
}