diff --git a/dns/index.go b/dns/index.go index 7b83e73..9142a7b 100644 --- a/dns/index.go +++ b/dns/index.go @@ -86,6 +86,8 @@ func RunOnce() { dnsSelected = &Dynadot{} case "dynv6": dnsSelected = &Dynv6{} + case "spaceship": + dnsSelected = &Spaceship{} default: dnsSelected = &Alidns{} } diff --git a/dns/spaceship.go b/dns/spaceship.go new file mode 100644 index 0000000..0b8e788 --- /dev/null +++ b/dns/spaceship.go @@ -0,0 +1,229 @@ +package dns + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/jeessy2/ddns-go/v6/config" + "github.com/jeessy2/ddns-go/v6/util" +) + +const spaceshipAPI = "https://spaceship.dev/api/v1/dns/records" +const maxRecords = 500 + +type Spaceship struct { + domains config.Domains + header http.Header + ttl int +} + +func (s *Spaceship) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { + s.domains.Ipv4Cache = ipv4cache + s.domains.Ipv6Cache = ipv6cache + s.domains.GetNewIp(dnsConf) + + s.ttl = 600 + if val, err := strconv.Atoi(dnsConf.TTL); err == nil { + s.ttl = val + } + s.header = http.Header{ + "X-API-Key": {dnsConf.DNS.ID}, + "X-API-Secret": {dnsConf.DNS.Secret}, + "Content-Type": {"application/json"}, + } +} + +func (s *Spaceship) AddUpdateDomainRecords() (domains config.Domains) { + for _, recordType := range []string{"A", "AAAA"} { + ip, domains := s.domains.GetNewIpResult(recordType) + if ip == "" { + continue + } + for _, domain := range domains { + hasUpdated, err := s.updateRecord(recordType, ip, domain) + if err != nil { + util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) + domain.UpdateStatus = config.UpdatedFailed + continue + } + if !hasUpdated { + util.Log("你的IP %s 没有变化, 域名 %s", ip, domain) + } else { + util.Log("更新域名解析 %s 成功! IP: %s", domain, ip) + domain.UpdateStatus = config.UpdatedSuccess + } + } + } + return s.domains +} + +func (s *Spaceship) request(domain *config.Domain, method string, query url.Values, payload []byte) (response []byte, err error) { + url := fmt.Sprintf("%s/%s", spaceshipAPI, domain.DomainName) + req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(payload))) + if err != nil { + return + } + req.Header = s.header + req.URL.RawQuery = query.Encode() + + cli := util.CreateHTTPClient() + resp, err := cli.Do(req) + if err != nil { + return + } + + defer resp.Body.Close() + response, err = io.ReadAll(resp.Body) + if err != nil { + return + } + + type DataItem struct { + Field string `json:"field"` + Details string `json:"details"` + } + + type ErrorResponse struct { + Detail string `json:"detail"` + Data *[]DataItem `json:"data,omitempty"` + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + var e ErrorResponse + err = json.Unmarshal(response, &e) + if err != nil { + return + } + err = fmt.Errorf("request error: %s", e.Detail) + return + } + + return +} + +func (s *Spaceship) createRecord(recordType string, ip string, domain *config.Domain) (err error) { + type Item struct { + Type string `json:"type"` + Address string `json:"address"` + Name string `json:"name"` + TTL int `json:"ttl"` + } + + type Payload struct { + Force bool `json:"force"` + Items []Item `json:"items"` + } + + payload := Payload{ + Force: true, + Items: []Item{ + { + Type: recordType, + Address: ip, + Name: domain.SubDomain, + TTL: s.ttl, + }, + }, + } + data, err := json.Marshal(payload) + if err != nil { + return + } + _, err = s.request(domain, "PUT", url.Values{}, data) + return +} + +func (s *Spaceship) getRecords(recordType string, domain *config.Domain) (ips []string, err error) { + type Group struct { + Type string `json:"type"` + } + + type Item struct { + Type string `json:"type"` + Address string `json:"address"` + Name string `json:"name"` + TTL int `json:"ttl"` + Group Group `json:"group"` + } + + type Response struct { + Items []Item `json:"items"` + Total int `json:"total"` + } + + resp, err := s.request(domain, "GET", url.Values{"take": {strconv.Itoa(maxRecords)}, "skip": {"0"}}, []byte{}) + if err != nil { + return + } + + var response Response + err = json.Unmarshal(resp, &response) + if err != nil { + return + } + + if response.Total > maxRecords { + err = fmt.Errorf("could not fetch all %d records in a one request", response.Total) + return + } + + for _, item := range response.Items { + if item.Type == recordType && item.Name == domain.SubDomain { + ips = append(ips, item.Address) + } + } + return +} + +func (s *Spaceship) deleteRecords(recordType string, domain *config.Domain, ips []string) (err error) { + if len(ips) == 0 { + return + } + + if len(ips) > maxRecords { + err = fmt.Errorf("could not delete all %d records in a one request", len(ips)) + return + } + + type Item struct { + Type string `json:"type"` + Address string `json:"address"` + Name string `json:"name"` + } + var payload []Item + for _, ip := range ips { + payload = append(payload, Item{ + Type: recordType, + Address: ip, + Name: domain.SubDomain, + }) + } + data, err := json.Marshal(payload) + if err != nil { + return + } + _, err = s.request(domain, "DELETE", url.Values{}, data) + return +} + +func (s *Spaceship) updateRecord(recordType string, ip string, domain *config.Domain) (hasUpdated bool, err error) { + ips, err := s.getRecords(recordType, domain) + if err != nil { + return + } + if len(ips) == 1 && ips[0] == ip { + return + } + err = s.deleteRecords(recordType, domain, ips) + if err != nil { + return + } + err = s.createRecord(recordType, ip, domain) + hasUpdated = true + return +} diff --git a/static/constant.js b/static/constant.js index 0799896..e6786b1 100644 --- a/static/constant.js +++ b/static/constant.js @@ -169,6 +169,17 @@ const DNS_PROVIDERS = { "zh-cn": "创建令牌", } }, + spaceship: { + name: { + "en": "Spaceship", + }, + idLabel: "API Key", + secretLabel: "API Secret", + helpHtml: { + "en": "Create API Key", + "zh-cn": "创建 API 密钥", + } + }, }; const SVG_CODE = {