NEW PROVIDER: Sakura Cloud (SAKURACLOUD) (#3086)

Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
Takashi Takizawa 2024-08-21 22:14:56 +09:00 committed by GitHub
parent e8eca6a31e
commit 864d45290f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 965 additions and 1 deletions

View file

@ -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)]*:+.*$"

1
OWNERS
View file

@ -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

View file

@ -57,6 +57,7 @@ Currently supported DNS providers:
- PowerDNS
- Realtime Register
- RWTH DNS-Admin
- Sakura Cloud
- SoftLayer
- TransIP
- Vultr

View file

@ -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)

View file

@ -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

View file

@ -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|

View file

@ -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",

View file

@ -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"

View file

@ -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
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}