package luadns import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/StackExchange/dnscontrol/v4/models" ) // Api layer for LuaDNS const ( apiURL = "https://api.luadns.com/v1" ) type luadnsProvider struct { domainIndex map[string]uint32 nameserversNames []string creds struct { email string apikey string } } type errorResponse struct { Status string `json:"status"` RequestID string `json:"request_id"` Message string `json:"message"` } type userInfoResponse struct { Email string `json:"email"` Name string `json:"name"` TTL uint32 `json:"ttl"` NameServers []string `json:"name_servers"` } type zoneRecord struct { ID uint32 `json:"id"` Name string `json:"name"` } type zoneResponse []zoneRecord type domainRecord struct { ID uint32 `json:"id"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` TTL uint32 `json:"ttl"` } type recordResponse []domainRecord type ( requestParams map[string]string jsonRequestParams map[string]any ) func (l *luadnsProvider) fetchAvailableNameservers() error { l.nameserversNames = nil bodyString, err := l.get("/users/me", "GET", requestParams{}) if err != nil { return fmt.Errorf("failed fetching available nameservers list from LuaDNS: %w", err) } var ui userInfoResponse if err := json.Unmarshal(bodyString, &ui); err != nil { return fmt.Errorf("failed to unmarshal available nameservers list from LuaDNS: %w", err) } l.nameserversNames = ui.NameServers return nil } func (l *luadnsProvider) fetchDomainList() error { l.domainIndex = map[string]uint32{} bodyString, err := l.get("/zones", "GET", requestParams{}) if err != nil { return fmt.Errorf("failed fetching domain list from LuaDNS: %w", err) } var dr zoneResponse if err := json.Unmarshal(bodyString, &dr); err != nil { return fmt.Errorf("failed to unmarshal domain list from LuaDNS: %w", err) } for _, domain := range dr { l.domainIndex[domain.Name] = domain.ID } return nil } func (l *luadnsProvider) getDomainID(name string) (uint32, error) { if l.domainIndex == nil { if err := l.fetchDomainList(); err != nil { return 0, err } } id, ok := l.domainIndex[name] if !ok { return 0, fmt.Errorf("'%s' not a zone in luadns account", name) } return id, nil } func (l *luadnsProvider) createDomain(domain string) error { params := jsonRequestParams{ "name": domain, } if _, err := l.get("/zones", "POST", params); err != nil { return fmt.Errorf("failed create domain (LuaDNS): %w", err) } return nil } func (l *luadnsProvider) createRecord(domainID uint32, rec jsonRequestParams) error { if _, err := l.get(fmt.Sprintf("/zones/%d/records", domainID), "POST", rec); err != nil { return fmt.Errorf("failed create record (LuaDNS): %w", err) } return nil } func (l *luadnsProvider) deleteRecord(domainID uint32, recordID uint32) error { if _, err := l.get(fmt.Sprintf("/zones/%d/records/%d", domainID, recordID), "DELETE", requestParams{}); err != nil { return fmt.Errorf("failed delete record (LuaDNS): %w", err) } return nil } func (l *luadnsProvider) modifyRecord(domainID uint32, recordID uint32, rec jsonRequestParams) error { if _, err := l.get(fmt.Sprintf("/zones/%d/records/%d", domainID, recordID), "PUT", rec); err != nil { return fmt.Errorf("failed update (LuaDNS): %w", err) } return nil } func (l *luadnsProvider) getRecords(domainID uint32) ([]domainRecord, error) { bodyString, err := l.get(fmt.Sprintf("/zones/%d/records", domainID), "GET", requestParams{}) if err != nil { return nil, fmt.Errorf("failed fetching record list from LuaDNS: %w", err) } var dr recordResponse if err := json.Unmarshal(bodyString, &dr); err != nil { return nil, fmt.Errorf("failed to unmarshal record response from LuaDNS: %w", err) } var records []domainRecord for _, rec := range dr { if rec.Type == "SOA" { continue } records = append(records, rec) } return records, nil } func (l *luadnsProvider) get(endpoint string, method string, params any) ([]byte, error) { client := &http.Client{} req, err := l.makeRequest(endpoint, method, params) if err != nil { return []byte{}, err } req.Header.Set("Accept", "application/json") req.SetBasicAuth(l.creds.email, l.creds.apikey) // LuaDNS has a rate limit of 1200 request per 5 minute. // So we do a very primitive rate limiting here - delay every request for 250ms - so max. 4 requests/second. time.Sleep(250 * time.Millisecond) resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode == http.StatusOK { bodyString, _ := io.ReadAll(resp.Body) return bodyString, nil } bodyString, _ := io.ReadAll(resp.Body) var errResp errorResponse err = json.Unmarshal(bodyString, &errResp) if err != nil { return bodyString, fmt.Errorf("LuaDNS API Error: %s URL:%s%s", string(bodyString), req.Host, req.URL.RequestURI()) } return bodyString, fmt.Errorf("LuaDNS API error: %s URL:%s%s", errResp.Message, req.Host, req.URL.RequestURI()) } func (l *luadnsProvider) makeRequest(endpoint string, method string, params any) (*http.Request, error) { switch v := params.(type) { case requestParams: req, err := http.NewRequest(method, apiURL+endpoint, nil) if err != nil { return nil, err } q := req.URL.Query() for pName, pValue := range v { q.Add(pName, pValue) } req.URL.RawQuery = q.Encode() return req, nil case jsonRequestParams: requestJSON, err := json.Marshal(params) if err != nil { return nil, err } req, err := http.NewRequest(method, apiURL+endpoint, bytes.NewBuffer(requestJSON)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return req, nil default: return nil, errors.New("invalid request type") } } func nativeToRecord(domain string, r *domainRecord) (*models.RecordConfig, error) { rc := &models.RecordConfig{ Type: r.Type, TTL: r.TTL, Original: r, } rc.SetLabelFromFQDN(r.Name, domain) var err error switch rtype := rc.Type; rtype { case "TXT": err = rc.SetTargetTXT(r.Content) default: err = rc.PopulateFromString(rtype, r.Content, domain) } return rc, err } func recordsToNative(rc *models.RecordConfig) jsonRequestParams { r := jsonRequestParams{ "name": rc.GetLabelFQDN() + ".", "type": rc.Type, "ttl": rc.TTL, } switch rtype := rc.Type; rtype { case "TXT": r["content"] = rc.GetTargetTXTJoined() default: r["content"] = rc.GetTargetCombined() } return r } func checkNS(dc *models.DomainConfig) { newList := make([]*models.RecordConfig, 0, len(dc.Records)) for _, rec := range dc.Records { // LuaDNS does not support changing the TTL of the default nameservers, so forcefully change the TTL to 86400. if rec.Type == "NS" && strings.HasSuffix(rec.GetTargetField(), ".luadns.net.") && rec.TTL != 86400 { rec.TTL = 86400 } newList = append(newList, rec) } dc.Records = newList }