diff --git a/OWNERS b/OWNERS index 6f6f2aaaf..5337e7fd5 100644 --- a/OWNERS +++ b/OWNERS @@ -31,4 +31,5 @@ providers/oracle @kallsyms providers/vultr @pgaskin providers/ovh @masterzen providers/powerdns @jpbede +providers/packetframe @hamptonmoore providers/transip @blackshadev diff --git a/README.md b/README.md index 360639648..696caba75 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Currently supported DNS providers: - OVH - OctoDNS - Oracle Cloud + - Packetframe - PowerDNS - SoftLayer - TransIP diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 3677c2807..93e8537c9 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -39,6 +39,7 @@
OPENSRS
ORACLE
OVH
+
PACKETFRAME
POWERDNS
ROUTE53
SOFTLAYER
@@ -151,6 +152,9 @@ + + + @@ -280,6 +284,9 @@ + + + Registrar @@ -385,6 +392,9 @@ + + + @@ -475,6 +485,7 @@ + @@ -540,6 +551,7 @@ + @@ -635,6 +647,7 @@ + @@ -742,6 +755,9 @@ + + + @@ -805,6 +821,7 @@ + @@ -859,6 +876,7 @@ + SRV @@ -964,6 +982,9 @@ + + + SSHFP @@ -1034,6 +1055,7 @@ + @@ -1123,6 +1145,7 @@ + @@ -1175,6 +1198,7 @@ + R53_ALIAS @@ -1216,6 +1240,7 @@ + @@ -1269,6 +1294,7 @@ + DS @@ -1332,6 +1358,7 @@ + @@ -1379,6 +1406,7 @@ + AKAMAICDN @@ -1508,6 +1536,9 @@ + + + @@ -1619,6 +1650,9 @@ + + + @@ -1751,6 +1785,9 @@ + + + get-zones @@ -1841,6 +1878,9 @@ + + + diff --git a/docs/_providers/packetframe.md b/docs/_providers/packetframe.md new file mode 100644 index 000000000..9fc732ad1 --- /dev/null +++ b/docs/_providers/packetframe.md @@ -0,0 +1,33 @@ +--- +name: Packetframe +title: Packetframe Provider +layout: default +jsId: PACKETFRAME +--- +# Packetframe Provider + +## Configuration +In your credentials file, you must provide your Packetframe Token which can be extracted from the `token` cookie on packetframe.com + +{% highlight json %} +{ + "packetframe": { + "token": "your-packetframe-token" + } +} +{% endhighlight %} + +## Metadata +This provider does not recognize any special metadata fields unique to Packetframe. + +## Usage +Example Javascript: + +{% highlight js %} +var REG_NONE = NewRegistrar('none', 'NONE') +var PACKETFRAME = NewDnsProvider("packetframe", "PACKETFRAME"); + +D("example.tld", REG_NONE, DnsProvider(PACKETFRAME), + A("test","1.2.3.4") +); +{%endhighlight%} diff --git a/docs/provider-list.md b/docs/provider-list.md index afb82fba5..b42aefea1 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -100,6 +100,7 @@ Providers in this category and their maintainers are: * `OPENSRS` @pierre-emmanuelJ * `ORACLE` @kallsyms * `OVH` @masterzen +* `PACKETFRAME` @hamptonmoore * `POWERDNS` @jpbede * `SOFTLAYER`@jamielennox * `TRANSIP` @blackshadev diff --git a/integrationTest/providers.json b/integrationTest/providers.json index e94cd0e4c..5f4059285 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -171,6 +171,10 @@ "serverName": "$POWERDNS_SERVERNAME", "domain": "$POWERDNS_DOMAIN" }, + "PACKETFRAME": { + "token": "$PACKETFRAME_TOKEN", + "domain": "$PACKETFRAME_DOMAIN" + }, "ROUTE53": { "KeyId": "$ROUTE53_KEY_ID", "SecretKey": "$ROUTE53_KEY", diff --git a/providers/_all/all.go b/providers/_all/all.go index 49aaeafc6..2b5b52a84 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -36,6 +36,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/opensrs" _ "github.com/StackExchange/dnscontrol/v3/providers/oracle" _ "github.com/StackExchange/dnscontrol/v3/providers/ovh" + _ "github.com/StackExchange/dnscontrol/v3/providers/packetframe" _ "github.com/StackExchange/dnscontrol/v3/providers/powerdns" _ "github.com/StackExchange/dnscontrol/v3/providers/route53" _ "github.com/StackExchange/dnscontrol/v3/providers/softlayer" diff --git a/providers/packetframe/api.go b/providers/packetframe/api.go new file mode 100644 index 000000000..65a272cda --- /dev/null +++ b/providers/packetframe/api.go @@ -0,0 +1,186 @@ +package packetframe + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +const ( + mediaType = "application/json" + defaultBaseURL = "https://packetframe.com/api/" +) + +type zone struct { + ID string `json:"id"` + Zone string `json:"zone"` + Users []string `json:"users"` + UserEmails []string `json:"user_emails"` +} + +type domainResponse struct { + Data struct { + Zones []zone `json:"zones"` + } `json:"data"` + Message string `json:"message"` + Success bool `json:"success"` +} + +type deleteRequest struct { + Record string `json:"record"` + Zone string `json:"zone"` +} + +type recordResponse struct { + Data struct { + Records []domainRecord `json:"records"` + } `json:"data"` + Message string `json:"message"` + Success bool `json:"success"` +} + +type domainRecord struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Value string `json:"value"` + TTL int `json:"ttl"` + Proxy bool `json:"proxy"` + Zone string `json:"zone"` +} + +func (c *packetframeProvider) fetchDomainList() error { + c.domainIndex = map[string]zone{} + dr := &domainResponse{} + endpoint := "dns/zones" + if err := c.get(endpoint, dr); err != nil { + return fmt.Errorf("failed fetching domain list (Packetframe): %w", err) + } + for _, zone := range dr.Data.Zones { + c.domainIndex[zone.Zone] = zone + } + + return nil +} + +func (c *packetframeProvider) getRecords(zoneID string) ([]domainRecord, error) { + var records []domainRecord + dr := &recordResponse{} + endpoint := "dns/records/" + zoneID + if err := c.get(endpoint, dr); err != nil { + return records, fmt.Errorf("failed fetching domain list (Packetframe): %w", err) + } + records = append(records, dr.Data.Records...) + + return records, nil +} + +func (c *packetframeProvider) createRecord(rec *domainRecord) (*domainRecord, error) { + endpoint := "dns/records" + + req, err := c.newRequest(http.MethodPost, endpoint, rec) + if err != nil { + return nil, err + } + + _, err = c.client.Do(req) + if err != nil { + return nil, err + } + + return rec, nil +} + +func (c *packetframeProvider) modifyRecord(rec *domainRecord) error { + endpoint := "dns/records" + + req, err := c.newRequest(http.MethodPut, endpoint, rec) + if err != nil { + return err + } + + _, err = c.client.Do(req) + if err != nil { + return err + } + + return nil +} + +func (c *packetframeProvider) deleteRecord(zoneID string, recordID string) error { + endpoint := "dns/records" + req, err := c.newRequest(http.MethodDelete, endpoint, deleteRequest{Zone: zoneID, Record: recordID}) + if err != nil { + return err + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return c.handleErrors(resp) + } + + return nil +} + +func (c *packetframeProvider) newRequest(method, endpoint string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + + u := c.baseURL.ResolveReference(rel) + + buf := new(bytes.Buffer) + if body != nil { + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", mediaType) + req.Header.Add("Accept", mediaType) + req.Header.Add("Authorization", "Token "+c.token) + return req, nil +} + +func (c *packetframeProvider) get(endpoint string, target interface{}) error { + req, err := c.newRequest(http.MethodGet, endpoint, nil) + if err != nil { + return err + } + resp, err := c.client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return c.handleErrors(resp) + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + return decoder.Decode(target) +} + +func (c *packetframeProvider) handleErrors(resp *http.Response) error { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + dr := &domainResponse{} + json.Unmarshal(body, &dr) + + return fmt.Errorf("packetframe API error: %s", dr.Message) +} diff --git a/providers/packetframe/auditrecords.go b/providers/packetframe/auditrecords.go new file mode 100644 index 000000000..df0823c18 --- /dev/null +++ b/providers/packetframe/auditrecords.go @@ -0,0 +1,11 @@ +package packetframe + +import ( + "github.com/StackExchange/dnscontrol/v3/models" +) + +// AuditRecords returns an error if any records are not +// supportable by this provider. +func AuditRecords(records []*models.RecordConfig) error { + return nil +} diff --git a/providers/packetframe/packetframeProvider.go b/providers/packetframe/packetframeProvider.go new file mode 100644 index 000000000..61f6712bf --- /dev/null +++ b/providers/packetframe/packetframeProvider.go @@ -0,0 +1,237 @@ +package packetframe + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +// packetframeProvider is the handle for this provider. +type packetframeProvider struct { + client *http.Client + baseURL *url.URL + token string + domainIndex map[string]zone +} + +var defaultNameServerNames = []string{ + "ns1.packetframe.com", + "ns2.packetframe.com", +} + +// newPacketframe creates the provider. +func newPacketframe(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + if m["token"] == "" { + return nil, fmt.Errorf("missing Packetframe token") + } + + baseURL, err := url.Parse(defaultBaseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL for Packetframe") + } + client := http.Client{} + + api := &packetframeProvider{client: &client, baseURL: baseURL, token: m["token"]} + + return api, nil +} + +var features = providers.DocumentationNotes{ + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), +} + +func init() { + fns := providers.DspFuncs{ + Initializer: newPacketframe, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("PACKETFRAME", fns, features) +} + +// GetNameservers returns the nameservers for a domain. +func (api *packetframeProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + return models.ToNameservers(defaultNameServerNames) +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (api *packetframeProvider) GetZoneRecords(domain string) (models.Records, error) { + + if api.domainIndex == nil { + if err := api.fetchDomainList(); err != nil { + return nil, err + } + } + zone, ok := api.domainIndex[domain+"."] + if !ok { + return nil, fmt.Errorf("%q not a zone in Packetframe account", domain) + } + + records, err := api.getRecords(zone.ID) + if err != nil { + return nil, fmt.Errorf("could not load records for domain %q", domain) + } + + existingRecords := make([]*models.RecordConfig, len(records)) + + dc := models.DomainConfig{ + Name: domain, + } + + for i := range records { + existingRecords[i] = toRc(&dc, &records[i]) + } + + return existingRecords, nil +} + +// GetDomainCorrections returns the corrections for a domain. +func (api *packetframeProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + dc.Punycode() + + if api.domainIndex == nil { + if err := api.fetchDomainList(); err != nil { + return nil, err + } + } + zone, ok := api.domainIndex[dc.Name+"."] + if !ok { + return nil, fmt.Errorf("no such zone %q in Packetframe account", dc.Name) + } + + records, err := api.getRecords(zone.ID) + if err != nil { + return nil, fmt.Errorf("could not load records for domain %q", dc.Name) + } + + existingRecords := make([]*models.RecordConfig, len(records)) + + for i := range records { + existingRecords[i] = toRc(dc, &records[i]) + } + + // Normalize + models.PostProcessRecords(existingRecords) + + differ := diff.New(dc) + _, create, delete, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + var corrections []*models.Correction + + for _, m := range create { + req, err := toReq(zone.ID, dc, m.Desired) + if err != nil { + return nil, err + } + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + _, err := api.createRecord(req) + return err + }, + } + corrections = append(corrections, corr) + } + + for _, m := range delete { + original := m.Existing.Original.(*domainRecord) + corr := &models.Correction{ + Msg: fmt.Sprintf("Deleting record %q from %q", original.ID, zone.Zone), + F: func() error { + err := api.deleteRecord(zone.ID, original.ID) + return err + }, + } + corrections = append(corrections, corr) + } + + for _, m := range modify { + original := m.Existing.Original.(*domainRecord) + req, _ := toReq(zone.ID, dc, m.Desired) + req.ID = original.ID + corr := &models.Correction{ + Msg: fmt.Sprintf("Modifying record %q from %q", original.ID, zone.Zone), + F: func() error { + err := api.modifyRecord(req) + return err + }, + } + corrections = append(corrections, corr) + } + + return corrections, nil +} + +func toReq(zoneID string, dc *models.DomainConfig, rc *models.RecordConfig) (*domainRecord, error) { + req := &domainRecord{ + Type: rc.Type, + TTL: int(rc.TTL), + Label: rc.GetLabel(), + Zone: zoneID, + } + + switch rc.Type { // #rtype_variations + case "A", "AAAA", "PTR", "TXT", "CNAME", "NS": + req.Value = rc.GetTargetField() + case "MX": + req.Value = fmt.Sprintf("%d %s", rc.MxPreference, rc.GetTargetField()) + case "SRV": + req.Value = fmt.Sprintf("%d %d %d %s", rc.SrvPriority, rc.SrvWeight, rc.SrvPort, rc.GetTargetField()) + default: + return nil, fmt.Errorf("packetframe.toReq rtype %q unimplemented", rc.Type) + } + + return req, nil +} + +func toRc(dc *models.DomainConfig, r *domainRecord) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: r.Type, + TTL: uint32(r.TTL), + Original: r, + } + + label := strings.TrimSuffix(r.Label, dc.Name+".") + label = strings.TrimSuffix(label, ".") + if label == "" { + label = "@" + } + rc.SetLabel(label, dc.Name) + + switch rtype := r.Type; rtype { // #rtype_variations + case "TXT": + rc.SetTargetTXTString(r.Value) + case "SRV": + spl := strings.Split(r.Value, " ") + prio, _ := strconv.ParseUint(spl[0], 10, 16) + weight, _ := strconv.ParseUint(spl[1], 10, 16) + port, _ := strconv.ParseUint(spl[2], 10, 16) + rc.SetTargetSRV(uint16(prio), uint16(weight), uint16(port), spl[3]) + case "MX": + spl := strings.Split(r.Value, " ") + prio, _ := strconv.ParseUint(spl[0], 10, 16) + rc.SetTargetMX(uint16(prio), spl[1]) + default: + rc.SetTarget(r.Value) + } + + return rc +}