diff --git a/.goreleaser.yml b/.goreleaser.yml index c24e4b9a9..2a7888231 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -39,7 +39,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|softlayer|transip|vultr).*:)+.*" + regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index 3a2c3403a..214b2a48f 100644 --- a/OWNERS +++ b/OWNERS @@ -46,6 +46,7 @@ providers/powerdns @jpbede providers/realtimeregister @PJEilers providers/route53 @tresni providers/rwth @mistererwin +providers/sakuracloud @ttkzw # providers/softlayer NEEDS VOLUNTEER providers/transip @blackshadev providers/vultr @pgaskin diff --git a/README.md b/README.md index 0a1bc0589..b1e962ac2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Currently supported DNS providers: - PowerDNS - Realtime Register - RWTH DNS-Admin +- Sakura Cloud - SoftLayer - TransIP - Vultr diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index f96c36227..efa9cdd8b 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -150,6 +150,7 @@ * [PowerDNS](provider/powerdns.md) * [Realtime Register](provider/realtimeregister.md) * [RWTH DNS-Admin](provider/rwth.md) +* [Sakura Cloud](provider/sakuracloud.md) * [SoftLayer DNS](provider/softlayer.md) * [TransIP](provider/transip.md) * [Vultr](provider/vultr.md) diff --git a/documentation/provider/sakuracloud.md b/documentation/provider/sakuracloud.md new file mode 100644 index 000000000..275649a9a --- /dev/null +++ b/documentation/provider/sakuracloud.md @@ -0,0 +1,95 @@ +This is the provider for [Sakura Cloud](https://cloud.sakura.ad.jp/). + +## Configuration +To use this provider, add an entry to `creds.json` with `TYPE` set to `SAKURACLOUD` +along with API credentials. + +Example: + +{% code title="creds.json" %} +```json +{ + "sakuracloud": { + "TYPE": "SAKURACLOUD", + "access_token": "your-access-token", + "access_token_secret": "your-access-token-secret" + } +} +``` +{% endcode %} + +The `endpoint` is optional. If omitted, the default endpoint is assumed. + +Endpoints are as follows: + +* `https://secure.sakura.ad.jp/cloud/zone/is1a/api/cloud/1.1` (Ishikari first Zone) +* `https://secure.sakura.ad.jp/cloud/zone/is1b/api/cloud/1.1` (Ishikari second Zone) +* `https://secure.sakura.ad.jp/cloud/zone/tk1a/api/cloud/1.1` (Tokyo first Zone) +* `https://secure.sakura.ad.jp/cloud/zone/tk1b/api/cloud/1.1` (Tokyo second Zone) + +DNS service is independent of zones, so you can use any of these endpoints. +The default is the Ishikari first Zone. + +Alternatively you can also use environment variables. + +```shell +export SAKURACLOUD_ACCESS_TOKEN="your-access-token" +export SAKURACLOUD_ACCESS_TOKEN_SECRET="your-access-token-secret" +``` + +{% code title="creds.json" %} +```json +{ + "sakuracloud": { + "TYPE": "SAKURACLOUD", + "access_token": "$SAKURACLOUD_ACCESS_TOKEN", + "access_token_secret": "$SAKURACLOUD_ACCESS_TOKEN_SECRET" + } +} +``` +{% endcode %} + +## Metadata +This provider does not recognize any special metadata fields unique to +Sakura Cloud. + +## Usage +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_SAKURACLOUD = NewDnsProvider("sakuracloud"); + +D("example.com", REG_NONE, DnsProvider(DSP_SAKURACLOUD), + A("test", "192.0.2.1"), +END); +``` +{% endcode %} + +`NAMESERVER` does not need to be set as the name servers for the +Sakura Cloud provider cannot be changed. + +`SOA` cannot be set as SOA record of Sakura Cloud provider cannot be changed. + +## Activation +Sakura Cloud depends on an [API Key](https://manual.sakura.ad.jp/cloud/api/apikey.html). + +When creating an API key, select "can modify settings" as "Access level". +if you plan to create zones, select "can create and delete resources" as +"Access level". +None of the options in the "Allow access to other services" field need +to be checked. + +## Caveats +The limitations of the Sakura Cloud DNS service are described in [the DNS manual](https://manual.sakura.ad.jp/cloud/appliance/dns/index.html), which is written in Japanese. + +The limitations not described in that manual are: + +* "Null MX", RFC 7505, is not supported. +* SRV records with a Target of "." are not supported. +* SRV records with Port "0" are not supported. +* CAA records with a property value longer than 64 bytes are not allowed. +* Owner names and RDATA targets containing the following labels are not allowed: + * example + * exampleN, where N is a numerical character diff --git a/documentation/providers.md b/documentation/providers.md index 7cb5550f9..91358cda2 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -62,6 +62,7 @@ If a feature is definitively not supported for whatever reason, we would also li | [`REALTIMEREGISTER`](provider/realtimeregister.md) | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ❔ | ✅ | ❌ | ❌ | ❔ | ❔ | ❌ | ✅ | ✅ | | [`ROUTE53`](provider/route53.md) | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❔ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ | ✅ | | [`RWTH`](provider/rwth.md) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❔ | ❔ | ❌ | ❌ | ✅ | ❔ | ✅ | ✅ | ❔ | ❌ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | +| [`SAKURACLOUD`](provider/sakuracloud.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | [`SOFTLAYER`](provider/softlayer.md) | ❌ | ✅ | ❌ | ❌ | ❔ | ❔ | ❔ | ❔ | ❌ | ❔ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ❔ | | [`TRANSIP`](provider/transip.md) | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | [`VULTR`](provider/vultr.md) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ✅ | ✅ | ❔ | ❌ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ | @@ -149,6 +150,7 @@ Providers in this category and their maintainers are: |[`REALTIMEREGISTER`](provider/realtimeregister.md)|@PJEilers| |[`ROUTE53`](provider/route53.md)|@tresni| |[`RWTH`](provider/rwth.md)|@MisterErwin| +|[`SAKURACLOUD`](provider/sakuracloud.md)|@ttkzw| |[`SOFTLAYER`](provider/softlayer.md)|@jamielennox| |[`TRANSIP`](provider/transip.md)|@blackshadev| |[`VULTR`](provider/vultr.md)|@pgaskin| diff --git a/integrationTest/providers.json b/integrationTest/providers.json index cf2550512..f4e50ce54 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -279,6 +279,12 @@ "TYPE": "ROUTE53", "domain": "$ROUTE53_DOMAIN" }, + "SAKURACLOUD": { + "TYPE": "SAKURACLOUD", + "access_token": "$SAKURACLOUD_ACCESS_TOKEN", + "access_token_secret": "$SAKURACLOUD_ACCESS_TOKEN_SECRET", + "domain": "$SAKURACLOUD_DOMAIN" + }, "SOFTLAYER": { "TYPE": "SOFTLAYER", "api_key": "$SL_API_KEY", diff --git a/providers/_all/all.go b/providers/_all/all.go index 9b99d6ed5..e8a8775b0 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -51,6 +51,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/realtimeregister" _ "github.com/StackExchange/dnscontrol/v4/providers/route53" _ "github.com/StackExchange/dnscontrol/v4/providers/rwth" + _ "github.com/StackExchange/dnscontrol/v4/providers/sakuracloud" _ "github.com/StackExchange/dnscontrol/v4/providers/softlayer" _ "github.com/StackExchange/dnscontrol/v4/providers/transip" _ "github.com/StackExchange/dnscontrol/v4/providers/vultr" diff --git a/providers/sakuracloud/api.go b/providers/sakuracloud/api.go new file mode 100644 index 000000000..4fe0aa806 --- /dev/null +++ b/providers/sakuracloud/api.go @@ -0,0 +1,486 @@ +// NOTE: As the API documentation of Sakura Cloud is written in Japanese +// and lacks further explanation, we have described the API data structures +// in English in the structure comments. +// +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/index.html + +package sakuracloud + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "time" +) + +// requestCommonServiceItem is the body structure of the request to create a zone or update zone data. +// +// Zone creation: +// +// POST /commonserviceitem +// +// { +// "CommonServiceItem": { +// "Name": "example.com", +// "Status": { +// "Zone": "example.com" +// }, +// "Settings": { +// "DNS": { +// "ResourceRecordSets": [] +// } +// }, +// "Provider": { +// "Class": "dns" +// }, +// } +// } +// +// Zone update: +// +// PUT /commonserviceitem/:commonserviceitemid +// +// { +// "CommonServiceItem": { +// "Settings": { +// "DNS": { +// "ResourceRecordSets": [ +// { +// "Name": "a", +// "Type": "A", +// "RData": "192.0.2.1", +// "TTL": 600 +// }, +// ... +// ] +// } +// } +// } +// } +// +// Reference: +// +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/#post_commonserviceitem +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/#put_commonserviceitem_commonserviceitemid +type requestCommonServiceItem struct { + CommonServiceItem commonServiceItem `json:"CommonServiceItem"` +} + +// responseCommonServiceItems is the body structure of the success response to get a list of zones. +// +// Request: +// +// GET /commonserviceitem +// +// Response body structure: +// +// { +// "From": 0, +// "Count": 1, +// "Total": 1, +// "CommonServiceItems": [ +// { +// "Index": 0, +// "ID": "999999999999", +// "Name": "example.com", +// "Description": "", +// "Settings": { +// "DNS": { +// "ResourceRecordSets": [ +// { +// "Name": "a", +// "Type": "A", +// "RData": "192.0.2.1", +// "TTL": 600 +// }, +// ... +// ] +// } +// }, +// "SettingsHash": "ffffffffffffffffffffffffffffffff", +// "Status": { +// "Zone": "example.com", +// "NS": [ +// "ns1.gslbN.sakura.ne.jp", +// "ns2.gslbN.sakura.ne.jp" +// ] +// }, +// "ServiceClass": "cloud/dns", +// "Availability": "available", +// "CreatedAt": "2006-01-02T15:04:05+07:00", +// "ModifiedAt": "2006-01-02T15:04:05+07:00", +// "Provider": { +// "ID": 9999999, +// "Class": "dns", +// "Name": "gslbN.sakura.ne.jp", +// "ServiceClass": "cloud/dns" +// }, +// "Icon": null, +// "Tags": [] +// } +// ], +// "is_ok": true +// } +// +// References: +// +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/#get_commonserviceitem +type responseCommonServiceItems struct { + From int `json:"From"` + Count int `json:"Count"` + Total int `json:"Total"` + CommonServiceItems []commonServiceItem `json:"CommonServiceItems"` + IsOk bool `json:"is_ok"` +} + +// responseCommonServiceItem is the body structure of the success response to get a zone or update zone data. +// +// Request: +// +// GET /commonserviceitem/:commonserviceitemid +// PUT /commonserviceitem/:commonserviceitemid +// +// Response body structure: +// +// { +// "CommonServiceItem": { +// "ID": "999999999999", +// "Name": "example.com", +// "Description": "", +// "Settings": { +// "DNS": { +// "ResourceRecordSets": [ +// { +// "Name": "a", +// "Type": "A", +// "RData": "192.0.2.1", +// "TTL": 600 +// }, +// ... +// ] +// } +// }, +// "SettingsHash": "ffffffffffffffffffffffffffffffff", +// "Status": { +// "Zone": "example.com", +// "NS": [ +// "ns1.gslbN.sakura.ne.jp", +// "ns2.gslbN.sakura.ne.jp" +// ] +// }, +// "ServiceClass": "cloud/dns", +// "Availability": "available", +// "CreatedAt": "2006-01-02T15:04:05+07:00", +// "ModifiedAt": "2006-01-02T15:04:05+07:00", +// "Provider": { +// "ID": 9999999, +// "Class": "dns", +// "Name": "gslbN.sakura.ne.jp", +// "ServiceClass": "cloud/dns" +// }, +// "Icon": null, +// "Tags": [] +// }, +// "Success": true, +// "is_ok": true +// } +// +// References: +// +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/#get_commonserviceitem_commonserviceitemid +// - https://manual.sakura.ad.jp/cloud-api/1.1/appliance/#put_commonserviceitem_commonserviceitemid +type responseCommonServiceItem struct { + CommonServiceItem commonServiceItem `json:"CommonServiceItem"` + Success bool `json:"Success"` + IsOk bool `json:"is_ok"` +} + +// errorResponse is the body structure of an error response. +// +// Response body structure: +// +// { +// "is_fatal": true, +// "serial": "ffffffffffffffffffffffffffffffff", +// "status": "401 Unauthorized", +// "error_code": "unauthorized", +// "error_msg": "error-unauthorized" +// } +type errorResponse struct { + IsFatal bool `json:"is_fatal"` + Serial string `json:"serial"` + Status string `json:"status"` + ErrorCode string `json:"error_code"` + ErrorMsg string `json:"error_msg"` +} + +// commonServiceItem is a resource structure. +type commonServiceItem struct { + ID string `json:"ID,omitempty"` + Name string `json:"Name,omitempty"` + Settings settings `json:"Settings"` + Status status `json:"Status,omitempty"` + ServiceClass string `json:"ServiceClass,omitempty"` + Provider provider `json:"Provider,omitempty"` +} + +// settings is a resource setting. +type settings struct { + DNS dNS `json:"DNS"` +} + +// dNS is a set of dNS resources. +type dNS struct { + ResourceRecordSets []domainRecord `json:"ResourceRecordSets"` +} + +// domainRecord is a resource record. +type domainRecord struct { + Name string `json:"Name"` + Type string `json:"Type"` + RData string `json:"RData"` + TTL uint32 `json:"TTL,omitempty"` +} + +// status is the metadata of a zone. +type status struct { + Zone string `json:"Zone,omitempty"` + NS []string `json:"NS,omitempty"` +} + +// provider is the metadata of a service. +type provider struct { + ID int `json:"ID,omitempty"` + Class string `json:"Class"` + Name string `json:"Name,omitempty"` + ServiceClass string `json:"ServiceClass,omitempty"` +} + +// sakuracloudAPI has information about the API of the Sakura Cloud. +type sakuracloudAPI struct { + accessToken string + accessTokenSecret string + baseURL url.URL + httpClient *http.Client + commonServiceItemMap map[string]*commonServiceItem +} + +func NewSakuracloudAPI(accessToken, accessTokenSecret, endpoint string) (*sakuracloudAPI, error) { + baseURL, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("endpoint_url parse error: %w", err) + } + + return &sakuracloudAPI{ + accessToken: accessToken, + accessTokenSecret: accessTokenSecret, + baseURL: *baseURL, + httpClient: &http.Client{ + Timeout: time.Minute, + }, + }, nil +} + +func (api *sakuracloudAPI) request(method, path string, data []byte) ([]byte, error) { + req, err := http.NewRequest(method, path, bytes.NewReader(data)) + if err != nil { + return nil, err + } + + req.SetBasicAuth(api.accessToken, api.accessTokenSecret) + req.Header.Add("Content-Type", "applicaiton/json; charset=UTF-8") + resp, err := api.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + var errResp errorResponse + err := json.Unmarshal(respBody, &errResp) + if err != nil { + return nil, err + } + // Since an error_msg uses HTML entities, unescape it. + return nil, fmt.Errorf("request failed: status: %s, serial: %s, error_code: %s, error_msg: %s", errResp.Status, errResp.Serial, errResp.ErrorCode, html.UnescapeString(errResp.ErrorMsg)) + } + + return respBody, nil +} + +// getCommonServiceItems return all the zones in the account +func (api *sakuracloudAPI) getCommonServiceItems() ([]*commonServiceItem, error) { + var items []*commonServiceItem + + nextFrom := 0 + count := 100 + for { + u := api.baseURL.JoinPath("/commonserviceitem") + + if nextFrom > 0 { + // The query string is similar to the flow-style YAML. + // {From: 0, Count: 10} + query := fmt.Sprintf("{From: %d, Count: %d}", nextFrom, count) + u.RawQuery = url.QueryEscape(query) + } + + respBody, err := api.request(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + var respData responseCommonServiceItems + err = json.Unmarshal(respBody, &respData) + if err != nil { + return nil, err + } + + if items == nil { + items = make([]*commonServiceItem, 0, respData.Total) + } + + for _, item := range respData.CommonServiceItems { + items = append(items, &item) + } + + count = respData.Count + nextFrom = respData.From + respData.Count + if nextFrom == respData.Total { + break + } + } + + return items, nil +} + +// GetCommonServiceItemMap return all the zones in the account +func (api *sakuracloudAPI) GetCommonServiceItemMap() (map[string]*commonServiceItem, error) { + if api.commonServiceItemMap != nil { + return api.commonServiceItemMap, nil + } + + items, err := api.getCommonServiceItems() + if err != nil { + return nil, err + } + + api.commonServiceItemMap = make(map[string]*commonServiceItem, len(items)) + for _, item := range items { + if item.ServiceClass != "cloud/dns" { + continue + } + api.commonServiceItemMap[item.Status.Zone] = item + } + + return api.commonServiceItemMap, nil +} + +// postCommonServiceItem submits a CommonServiceItem to the API and create the zone. +func (api *sakuracloudAPI) postCommonServiceItem(reqItem requestCommonServiceItem) (*commonServiceItem, error) { + reqBody, err := json.Marshal(reqItem) + if err != nil { + return nil, err + } + + u := api.baseURL.JoinPath("/commonserviceitem") + respBody, err := api.request(http.MethodPost, u.String(), reqBody) + if err != nil { + return nil, err + } + + var respData responseCommonServiceItem + err = json.Unmarshal(respBody, &respData) + if err != nil { + return nil, err + } + + return &respData.CommonServiceItem, nil +} + +// CreateZone submits a CommonServiceItem to the API and create the zone. +func (api *sakuracloudAPI) CreateZone(domain string) error { + reqItem := requestCommonServiceItem{ + CommonServiceItem: commonServiceItem{ + Name: domain, + Status: status{ + Zone: domain, + }, + Settings: settings{ + DNS: dNS{ + ResourceRecordSets: []domainRecord{}, + }, + }, + Provider: provider{ + Class: "dns", + }, + }, + } + + item, err := api.postCommonServiceItem(reqItem) + if err != nil { + return err + } + + api.commonServiceItemMap[domain] = item + return nil +} + +// putCommonServiceItem submits a CommonServiceItem to the API and updates the zone data. +func (api *sakuracloudAPI) putCommonServiceItem(id string, reqItem requestCommonServiceItem) (*commonServiceItem, error) { + reqBody, err := json.Marshal(reqItem) + if err != nil { + return nil, err + } + + u := api.baseURL.JoinPath("/commonserviceitem/").JoinPath(id) + respBody, err := api.request(http.MethodPut, u.String(), reqBody) + if err != nil { + return nil, err + } + + var respData responseCommonServiceItem + err = json.Unmarshal(respBody, &respData) + if err != nil { + return nil, err + } + + return &respData.CommonServiceItem, nil +} + +// UpdateZone submits a CommonServiceItem to the API and updates the zone data. +func (api *sakuracloudAPI) UpdateZone(domain string, domainRecords []domainRecord) error { + drs := make([]domainRecord, 0, len(domainRecords)-2) // Removes 2 NS records. + for _, r := range domainRecords { + if r.Type == "NS" && r.Name == "@" { + continue + } + drs = append(drs, r) + } + + reqItem := requestCommonServiceItem{ + CommonServiceItem: commonServiceItem{ + Settings: settings{ + DNS: dNS{ + ResourceRecordSets: drs, + }, + }, + }, + } + + item, err := api.putCommonServiceItem(api.commonServiceItemMap[domain].ID, reqItem) + if err != nil { + return err + } + + api.commonServiceItemMap[domain] = item + return nil +} diff --git a/providers/sakuracloud/auditrecords.go b/providers/sakuracloud/auditrecords.go new file mode 100644 index 000000000..47ad06144 --- /dev/null +++ b/providers/sakuracloud/auditrecords.go @@ -0,0 +1,93 @@ +package sakuracloud + +import ( + "fmt" + "regexp" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/rejectif" + "github.com/miekg/dns" +) + +// 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{} + + a.Add("MX", rejectif.MxNull) // Last verified 2024-08-09 + + a.Add("SRV", rejectif.SrvHasNullTarget) // Last verified 2024-08-09 + + a.Add("TXT", rejectif.TxtHasBackslash) // Last verified 2024-08-09 + + a.Add("TXT", rejectif.TxtHasDoubleQuotes) // Last verified 2024-08-09 + + a.Add("TXT", rejectif.TxtHasUnpairedDoubleQuotes) // Last verified 2024-08-09 + + a.Add("TXT", rejectif.TxtLongerThan(500)) // Last verified 2024-08-09 + + a.Add("CAA", rejectifCaaLongerThan(64)) // Last verified 2024-08-09 + + a.Add("NS", rejectifNsPointsToOrigin) // Last verified 2024-08-09 + + for _, t := range []string{"ALIAS", "CNAME", "HTTPS", "MX", "NS", "PTR", "SRV", "SVCB"} { + a.Add(t, rejectifTargetHasExample) // Last verified 2024-08-09 + } + + for _, t := range []string{"A", "AAAA", "ALIAS", "CAA", "CNAME", "HTTPS", "MX", "NS", "PTR", "SRV", "SVCB", "TXT"} { + a.Add(t, rejectifLabelHasExample) // Last verified 2024-08-09 + } + return a.Audit(records) +} + +// rejectifCaaLongerThan returns a function that audits CAA records +// where the length of the property value is greater than maxLength. +func rejectifCaaLongerThan(maxLength int) func(rc *models.RecordConfig) error { + return func(rc *models.RecordConfig) error { + m := maxLength + if len(rc.GetTargetField()) > m { + return fmt.Errorf("CAA record longer than %d octets (chars)", m) + } + return nil + } +} + +// rejectifNsPointsToOrigin audits NS records that point to the origin. +func rejectifNsPointsToOrigin(rc *models.RecordConfig) error { + originFQDN := strings.TrimPrefix(rc.GetLabelFQDN(), rc.GetLabel()+".") + "." + if originFQDN == rc.GetTargetField() { + return fmt.Errorf("NS record points to the origin: %s", rc.GetTargetField()) + } + return nil +} + +var labelExampleRe = regexp.MustCompile(`^example[0-9]?$`) + +func hasLabelExample(domain string) error { + for _, l := range dns.SplitDomainName(domain) { + if labelExampleRe.MatchString(l) { + return fmt.Errorf("label contains `example`: %s", domain) + } + } + return nil +} + +// rejectifTargetHasExample returns a function that audits RDATA targets +// containing the following labels: +// +// - example +// - exampleN, where N is a numerical character +func rejectifTargetHasExample(rc *models.RecordConfig) error { + return hasLabelExample(rc.GetTargetField()) +} + +// rejectifLabelHasExample returns a function that audits owner names +// containing the following labels: +// +// - example +// - exampleN, where N is a numerical character +func rejectifLabelHasExample(rc *models.RecordConfig) error { + return hasLabelExample(rc.GetLabel()) +} diff --git a/providers/sakuracloud/convert.go b/providers/sakuracloud/convert.go new file mode 100644 index 000000000..20f7677ff --- /dev/null +++ b/providers/sakuracloud/convert.go @@ -0,0 +1,47 @@ +package sakuracloud + +import ( + "github.com/StackExchange/dnscontrol/v4/models" +) + +const defaultTTL = uint32(3600) + +func toRc(domain string, r domainRecord) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: r.Type, + TTL: r.TTL, + Original: r, + } + if r.TTL == 0 { + rc.TTL = defaultTTL + } + + rc.SetLabel(r.Name, domain) + + switch r.Type { + case "TXT": + // TXT records are stored verbatim; no quoting/escaping to parse. + rc.SetTargetTXT(r.RData) + default: + rc.PopulateFromString(r.Type, r.RData, domain) + } + + return rc +} + +func toNative(rc *models.RecordConfig) domainRecord { + rr := domainRecord{ + Name: rc.GetLabel(), + Type: rc.Type, + RData: rc.String(), + } + if rc.TTL != defaultTTL { + rr.TTL = rc.TTL + } + + switch rc.Type { + case "TXT": + rr.RData = rc.GetTargetTXTJoined() + } + return rr +} diff --git a/providers/sakuracloud/listzones.go b/providers/sakuracloud/listzones.go new file mode 100644 index 000000000..e408c4f67 --- /dev/null +++ b/providers/sakuracloud/listzones.go @@ -0,0 +1,32 @@ +package sakuracloud + +import "github.com/StackExchange/dnscontrol/v4/pkg/printer" + +// ListZones return all the zones in the account +func (s *sakuracloudProvider) ListZones() ([]string, error) { + itemMap, err := s.api.GetCommonServiceItemMap() + if err != nil { + return nil, err + } + + var zones []string + for _, item := range itemMap { + zones = append(zones, item.Status.Zone) + } + return zones, nil +} + +// EnsureZoneExists creates a zone if it does not exist +func (s *sakuracloudProvider) EnsureZoneExists(domain string) error { + itemMap, err := s.api.GetCommonServiceItemMap() + if err != nil { + return err + } + + if _, ok := itemMap[domain]; ok { + return nil + } + + printer.Printf("Adding zone for %s to Sakura Cloud account\n", domain) + return s.api.CreateZone(domain) +} diff --git a/providers/sakuracloud/records.go b/providers/sakuracloud/records.go new file mode 100644 index 000000000..6dd434fe0 --- /dev/null +++ b/providers/sakuracloud/records.go @@ -0,0 +1,91 @@ +package sakuracloud + +import ( + "fmt" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" +) + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (s *sakuracloudProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { + itemMap, err := s.api.GetCommonServiceItemMap() + if err != nil { + return nil, err + } + + item, ok := itemMap[domain] + if !ok { + return nil, errNoExist{domain} + } + + existingRecords := make([]*models.RecordConfig, 0, len(item.Status.NS)+len(item.Settings.DNS.ResourceRecordSets)) + + for _, ns := range item.Status.NS { + // CommonServiceItem.Status.NS fields do not end with a dot. + // Therefore, a dot is added at the end to make it an absolute domain name. + // + // "Status": { + // "Zone": "example.com", + // "NS": [ + // "ns1.gslbN.sakura.ne.jp", + // "ns2.gslbN.sakura.ne.jp" + // ] + // }, + rc := &models.RecordConfig{ + Type: "NS", + TTL: defaultTTL, + Original: ns, + } + rc.SetLabel("@", domain) + if err := rc.PopulateFromString("NS", ns+".", domain); err != nil { + return nil, fmt.Errorf("unparsable record received: %w", err) + } + existingRecords = append(existingRecords, rc) + } + + for _, dr := range item.Settings.DNS.ResourceRecordSets { + rc := toRc(domain, dr) + existingRecords = append(existingRecords, rc) + } + return existingRecords, nil +} + +// GetZoneRecordsCorrections gets the records of a zone and returns them in RecordConfig format. +func (s *sakuracloudProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, error) { + var corrections []*models.Correction + + // The name servers for the Sakura cloud provider cannot be changed. + // These default TTL is 3600 and the default TTL of DNSControl is 300, so NS corrections can be found. + // To prevent this, match TTL of DNSControl to one of Sakura Cloud provider. + for _, rc := range dc.Records { + if rc.Type == "NS" && rc.Name == "@" { + rc.TTL = defaultTTL + } + } + + msgs, changes, err := diff2.ByZone(existing, dc, nil) + if err != nil { + return nil, err + } + if !changes { + return nil, nil + } + msg := strings.Join(msgs, "\n") + + corrections = append(corrections, + &models.Correction{ + Msg: msg, + F: func() error { + drs := make([]domainRecord, 0, len(dc.Records)) + for _, rc := range dc.Records { + drs = append(drs, toNative(rc)) + } + return s.api.UpdateZone(dc.Name, drs) + }, + }, + ) + + return corrections, nil +} diff --git a/providers/sakuracloud/sakuracloudProvider.go b/providers/sakuracloud/sakuracloudProvider.go new file mode 100644 index 000000000..bdf29a24b --- /dev/null +++ b/providers/sakuracloud/sakuracloudProvider.go @@ -0,0 +1,108 @@ +package sakuracloud + +import ( + "encoding/json" + "fmt" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/providers" +) + +const defaultEndpoint = "https://secure.sakura.ad.jp/cloud/zone/is1a/api/cloud/1.1" + +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanAutoDNSSEC: providers.Cannot(), + providers.CanConcur: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDHCID: providers.Cannot(), + providers.CanUseDNAME: providers.Cannot(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseDSForChildren: providers.Cannot(), + providers.CanUseHTTPS: providers.Can(), + providers.CanUseLOC: providers.Cannot(), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSOA: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseSVCB: providers.Can(), + providers.CanUseTLSA: providers.Cannot(), + providers.CanUseDNSKEY: providers.Cannot(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "SAKURACLOUD" + const providerMaintainer = "@ttkzw" + fns := providers.DspFuncs{ + Initializer: newSakuracloudDsp, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +type sakuracloudProvider struct { + api *sakuracloudAPI +} + +func newSakuracloudDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + return newSakuracloud(conf, metadata) +} + +// newDSP initializes a Sakura Cloud DNSServiceProvider. +func newSakuracloud(config map[string]string, _ json.RawMessage) (*sakuracloudProvider, error) { + // config -- the key/values from creds.json + accessToken := config["access_token"] + if accessToken == "" { + return nil, fmt.Errorf("access_token is required") + } + + accessTokenSecret := config["access_token_secret"] + if accessTokenSecret == "" { + return nil, fmt.Errorf("access_token_secret is required") + } + + endpoint := config["endpoint"] + if endpoint == "" { + endpoint = defaultEndpoint + } + + api, err := NewSakuracloudAPI(accessToken, accessTokenSecret, endpoint) + if err != nil { + return nil, err + } + dsp := &sakuracloudProvider{ + api: api, + } + return dsp, nil +} + +type errNoExist struct { + domain string +} + +func (e errNoExist) Error() string { + return fmt.Sprintf("Zone %s not found in your Sakura Cloud account", e.domain) +} + +// GetNameservers returns the current nameservers for a domain. +func (s *sakuracloudProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + itemMap, err := s.api.GetCommonServiceItemMap() + if err != nil { + return nil, err + } + + item, ok := itemMap[domain] + if !ok { + return nil, errNoExist{domain} + } + + return models.ToNameservers(item.Status.NS) +}