// 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", "application/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 { item := item 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 }