diff --git a/OWNERS b/OWNERS index ff50183b7b..02bfbb1f5b 100644 --- a/OWNERS +++ b/OWNERS @@ -26,6 +26,7 @@ providers/hetzner @das7pad providers/hexonet @KaiSchwarz-cnic providers/hostingde @juliusrickert providers/huaweicloud @huihuimoe +providers/infomaniak @jbelien providers/internetbs @pragmaton providers/inwx @patschi providers/linode @koesie10 diff --git a/README.md b/README.md index ce8357570e..fd203a1c76 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Currently supported DNS providers: - hosting.de - Huawei Cloud DNS - Hurricane Electric DNS +- Infomaniak - INWX - Linode - Loopia diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 2c39e1b807..a008f0cc65 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -131,6 +131,7 @@ * [hosting.de](provider/hostingde.md) * [Huawei Cloud DNS](provider/huaweicloud.md) * [Hurricane Electric DNS](provider/hedns.md) +* [Infomaniak](provider/infomaniak.md) * [Internet.bs](provider/internetbs.md) * [INWX](provider/inwx.md) * [Linode](provider/linode.md) diff --git a/documentation/provider/infomaniak.md b/documentation/provider/infomaniak.md new file mode 100644 index 0000000000..e2fe0febb6 --- /dev/null +++ b/documentation/provider/infomaniak.md @@ -0,0 +1,38 @@ +This is the provider for [Infomaniak](https://www.infomaniak.com/). + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `INFOMANIAK` along with a Infomaniak account personal access token. + +Examples: + +{% code title="creds.json" %} +```json +{ + "infomaniak": { + "TYPE": "INFOMANIAK", + "token": "your-infomaniak-account-access-token", + } +} +``` +{% endcode %} + +## Metadata +This provider does not recognize any special metadata fields unique to Infomaniak. + +## Usage +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_INFOMANIAK = NewDnsProvider("infomaniak"); + +D("example.com", REG_NONE, DnsProvider(DSP_INFOMANIAK), + A("test", "1.2.3.4"), +); +``` +{% endcode %} + +## Activation +DNSControl depends on a Infomaniak account personal access token. diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index 860e62e843..2dd2613df7 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -181,6 +181,11 @@ "TYPE": "HUAWEICLOUD", "domain": "$HUAWEICLOUD_DOMAIN" }, + "INFOMANIAK": { + "TYPE": "INFOMANIAK", + "domain": "$INFOMANIAK_DOMAIN", + "token": "$INFOMANIAK_TOKEN" + }, "INWX": { "TYPE": "INWX", "domain": "$INWX_DOMAIN", diff --git a/providers/_all/all.go b/providers/_all/all.go index c4f4820ffb..0fa3c7106b 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -31,6 +31,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/hexonet" _ "github.com/StackExchange/dnscontrol/v4/providers/hostingde" _ "github.com/StackExchange/dnscontrol/v4/providers/huaweicloud" + _ "github.com/StackExchange/dnscontrol/v4/providers/infomaniak" _ "github.com/StackExchange/dnscontrol/v4/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v4/providers/inwx" _ "github.com/StackExchange/dnscontrol/v4/providers/linode" diff --git a/providers/infomaniak/api.go b/providers/infomaniak/api.go new file mode 100644 index 0000000000..ea30fd825c --- /dev/null +++ b/providers/infomaniak/api.go @@ -0,0 +1,186 @@ +package infomaniak + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +const baseURL = "https://api.infomaniak.com/2" + +type dnssecRecord struct { + IsEnabled bool `json:"is_enabled"` +} + +type errorRecord struct { + Code string `json:"code"` + Description string `json:"description"` +} + +type dnsZoneResponse struct { + Result string `json:"result"` + Data dnsZone `json:"data,omitempty"` + Error errorRecord `json:"error,omitempty"` +} + +type dnsRecordsResponse struct { + Result string `json:"result"` + Data []dnsRecord `json:"data,omitempty"` + Error errorRecord `json:"error,omitempty"` +} + +type dnsRecordResponse struct { + Result string `json:"result"` + Data dnsRecord `json:"data,omitempty"` + Error errorRecord `json:"error,omitempty"` +} + +type boolResponse struct { + Result string `json:"result"` + Data bool `json:"data,omitempty"` + Error errorRecord `json:"error,omitempty"` +} +type dnsZone struct { + ID int64 `json:"id,omitempty"` + FQDN string `json:"fqdn,omitempty"` + DNSSEC dnssecRecord `json:"dnssec,omitempty"` + Nameservers []string `json:"nameservers,omitempty"` +} + +type dnsRecord struct { + ID int64 `json:"id,omitempty"` + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + TTL int64 `json:"ttl,omitempty"` + Target string `json:"target,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +type dnsRecordCreate struct { + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + TTL int64 `json:"ttl,omitempty"` + Target string `json:"target,omitempty"` +} + +// Get zone information +// See https://developer.infomaniak.com/docs/api/get/2/zones/%7Bzone%7D +func (p *infomaniakProvider) getDNSZone(zone string) (*dnsZone, error) { + reqURL := fmt.Sprintf("%s/zones/%s", baseURL, zone) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+p.apiToken) + req.Header.Add("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + response := &dnsZoneResponse{} + err = json.NewDecoder(res.Body).Decode(response) + if err != nil { + return nil, err + } + + return &response.Data, nil +} + +// Retrieve all dns record for a given zone +// See https://developer.infomaniak.com/docs/api/get/2/zones/%7Bzone%7D/records +func (p *infomaniakProvider) getDNSRecords(zone string) ([]dnsRecord, error) { + reqURL := fmt.Sprintf("%s/zones/%s/records", baseURL, zone) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+p.apiToken) + req.Header.Add("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + response := &dnsRecordsResponse{} + err = json.NewDecoder(res.Body).Decode(response) + if err != nil { + return nil, err + } + + return response.Data, nil +} + +// Delete a dns record +// See https://developer.infomaniak.com/docs/api/delete/2/zones/%7Bzone%7D/records/%7Brecord%7D +func (p *infomaniakProvider) deleteDNSRecord(zone string, recordID string) error { + reqURL := fmt.Sprintf("%s/zones/%s/records/%s", baseURL, zone, recordID) + + req, err := http.NewRequest(http.MethodDelete, reqURL, nil) + if err != nil { + return err + } + req.Header.Add("Authorization", "Bearer "+p.apiToken) + req.Header.Add("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + response := &boolResponse{} + err = json.NewDecoder(res.Body).Decode(response) + if err != nil { + return err + } + + if response.Result == "error" { + return fmt.Errorf("failed to delete record %s in zone %s: %s", recordID, zone, response.Error.Description) + } + + return nil +} + +// Create a dns record in a given zone +// See https://developer.infomaniak.com/docs/api/post/2/zones/%7Bzone%7D/records +func (p *infomaniakProvider) createDNSRecord(zone string, rec *dnsRecordCreate) (*dnsRecord, error) { + reqURL := fmt.Sprintf("%s/zones/%s/records", baseURL, zone) + + data, err := json.Marshal(rec) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+p.apiToken) + req.Header.Add("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + response := &dnsRecordResponse{} + err = json.NewDecoder(res.Body).Decode(response) + if err != nil { + return nil, err + } + + if response.Result == "error" { + return nil, fmt.Errorf("failed to create %s record in zone %s: %s", rec.Type, zone, response.Error.Description) + } + + return &response.Data, nil +} diff --git a/providers/infomaniak/auditrecords.go b/providers/infomaniak/auditrecords.go new file mode 100644 index 0000000000..549c82ded8 --- /dev/null +++ b/providers/infomaniak/auditrecords.go @@ -0,0 +1,15 @@ +package infomaniak + +import ( + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/rejectif" +) + +// AuditRecords returns a list of errors corresponding to the records +// that aren't supported by this provider. If all records are +// supported, an empty list is returned. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + + return a.Audit(records) +} diff --git a/providers/infomaniak/infomaniakProvider.go b/providers/infomaniak/infomaniakProvider.go new file mode 100644 index 0000000000..8ad1667dfd --- /dev/null +++ b/providers/infomaniak/infomaniakProvider.go @@ -0,0 +1,125 @@ +package infomaniak + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/StackExchange/dnscontrol/v4/providers" +) + +// infomaniakProvider is the handle for operations. +type infomaniakProvider struct { + apiToken string // the account access token +} + +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanGetZones: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDNAME: providers.Can(), + providers.CanUseDS: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Can(), + providers.CanUseSRV: providers.Can(), + // providers.DocCreateDomains: providers.Can(), +} + +func newInfomaniak(m map[string]string, message json.RawMessage) (providers.DNSServiceProvider, error) { + api := &infomaniakProvider{} + api.apiToken = m["token"] + if api.apiToken == "" { + return nil, errors.New("missing Infomaniak personal access token") + } + + return api, nil +} + +func init() { + const providerName = "INFOMANIAK" + const providerMaintainer = "@jbelien" + fns := providers.DspFuncs{ + Initializer: newInfomaniak, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +func (p *infomaniakProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + zone, err := p.getDNSZone(domain) + if err != nil { + return nil, err + } + + return models.ToNameservers(zone.Nameservers) +} + +func (p *infomaniakProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { + records, err := p.getDNSRecords(domain) + if err != nil { + return nil, err + } + + cleanRecords := make(models.Records, 0) + + for _, r := range records { + recConfig := &models.RecordConfig{ + Original: r, + TTL: uint32(r.TTL), + Type: r.Type, + } + recConfig.SetLabelFromFQDN(r.Source, domain) + recConfig.SetTarget(r.Target) + + cleanRecords = append(cleanRecords, recConfig) + } + + return cleanRecords, nil +} + +func (p *infomaniakProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + var corrections []*models.Correction + + changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, nil) + if err != nil { + return nil, 0, err + } + + for _, change := range changes { + switch change.Type { + case diff2.REPORT: + corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined}) + case diff2.CHANGE: + fmt.Printf("CHANGE: %+v\n", change.New) + // corrections = append(corrections, &models.Correction{ + // Msg: change.Msgs[0], + // F: func() error { + // return p.updateRecord(change.Old[0].Original.(dnsRecord), change.New[0], dc.Name) + // }, + // }) + case diff2.CREATE: + fmt.Printf("CREATE: %+v\n", change.New) + // corrections = append(corrections, &models.Correction{ + // Msg: change.Msgs[0], + // F: func() error { + // _, err := p.createDNSRecord(dc.Name, change.New[0]) + // return err + // }, + // }) + case diff2.DELETE: + rec := change.Old[0].Original.(dnsRecord) + corrections = append(corrections, &models.Correction{ + Msg: change.Msgs[0], + F: func() error { + return p.deleteDNSRecord(dc.Name, fmt.Sprintf("%v", rec.ID)) + }, + }) + } + } + + return corrections, actualChangeCount, nil +}