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 @@
|
+
+
+ |
@@ -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 @@
|
|
|
+ |
@@ -964,6 +982,9 @@
|
+
+
+ |
@@ -1034,6 +1055,7 @@
|
+ |
|
@@ -1123,6 +1145,7 @@
|
+ |
|
@@ -1175,6 +1198,7 @@
|
|
|
+ |
@@ -1216,6 +1240,7 @@
|
|
|
+ |
|
@@ -1269,6 +1294,7 @@
|
|
|
+ |
@@ -1332,6 +1358,7 @@
|
|
|
+ |
|
@@ -1379,6 +1406,7 @@
|
|
|
+ |
@@ -1508,6 +1536,9 @@
|
+
+
+ |
|
@@ -1619,6 +1650,9 @@
|
+
+
+ |
|
@@ -1751,6 +1785,9 @@
|
+
+
+ |
@@ -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
+}