feat(providers): support name.com (#474)

This commit is contained in:
Joseph Diekhoff
2023-06-15 01:29:36 -04:00
committed by GitHub
parent 4ac2bd7933
commit 06b6288e58
13 changed files with 432 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ Light container updating DNS A and/or AAAA records periodically for multiple DNS
- INWX
- Linode
- LuaDNS
- Name.com
- Namecheap
- Netcup
- NoIP
@@ -184,6 +185,7 @@ Check the documentation for your DNS provider:
- [INWX](https://github.com/qdm12/ddns-updater/blob/master/docs/inwx.md)
- [Linode](https://github.com/qdm12/ddns-updater/blob/master/docs/linode.md)
- [LuaDNS](https://github.com/qdm12/ddns-updater/blob/master/docs/luadns.md)
- [Name.com](https://github.com/qdm12/ddns-updater/blob/master/docs/name.com.md)
- [Namecheap](https://github.com/qdm12/ddns-updater/blob/master/docs/namecheap.md)
- [Netcup](https://github.com/qdm12/ddns-updater/blob/master/docs/netcup.md)
- [NoIP](https://github.com/qdm12/ddns-updater/blob/master/docs/noip.md)

33
docs/name.com.md Normal file
View File

@@ -0,0 +1,33 @@
# Name.com
<a href="https://www.name.com"><img src="../readme/name.svg" alt="drawing" width="25%"/></a>
## Configuration
### Example
```json
{
"settings": [
{
"provider": "name.com",
"domain": "domain.com",
"host": "@",
"username": "username",
"token": "token"
}
]
}
```
### Compulsory parameters
- `"domain"`
- `"host"` is your host and can be a subdomain, `"@"` or `"*"` generally
- `"username"` is your account username
- `"token"` which you can obtain from [www.name.com/account/settings/api](https://www.name.com/account/settings/api)
### Optional parameters
- `"ttl"` is the time this record can be cached for in seconds. Name.com allows a minimum TTL of 300, or 5 minutes. Name.com defaults to 300 if not provided.
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`

View File

@@ -30,6 +30,7 @@ const (
Linode models.Provider = "linode"
LuaDNS models.Provider = "luadns"
Namecheap models.Provider = "namecheap"
NameCom models.Provider = "name.com"
Netcup models.Provider = "netcup"
Njalla models.Provider = "njalla"
NoIP models.Provider = "noip"
@@ -72,6 +73,7 @@ func ProviderChoices() []models.Provider {
Linode,
LuaDNS,
Namecheap,
NameCom,
Njalla,
NoIP,
OpenDNS,

View File

@@ -12,6 +12,7 @@ var (
ErrConflictingRecord = errors.New("conflicting record")
ErrDNSServerSide = errors.New("server side DNS error")
ErrDomainDisabled = errors.New("record disabled")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainIDNotFound = errors.New("ID not found in domain record")
ErrFeatureUnavailable = errors.New("feature is not available to the user")
ErrHostnameNotExists = errors.New("hostname does not exist")

View File

@@ -18,6 +18,7 @@ var (
ErrEmptyAccessKeyID = errors.New("empty access key id")
ErrEmptyAccessKeySecret = errors.New("empty key secret")
ErrEmptyTTL = errors.New("TTL is not set")
ErrTTLTooLow = errors.New("TTL is too low")
ErrEmptyUsername = errors.New("empty username")
ErrEmptyZoneIdentifier = errors.New("empty zone identifier")
ErrEmptyHost = errors.New("host cannot be empty")

View File

@@ -36,6 +36,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/linode"
"github.com/qdm12/ddns-updater/internal/provider/providers/luadns"
"github.com/qdm12/ddns-updater/internal/provider/providers/namecheap"
"github.com/qdm12/ddns-updater/internal/provider/providers/namecom"
"github.com/qdm12/ddns-updater/internal/provider/providers/netcup"
"github.com/qdm12/ddns-updater/internal/provider/providers/njalla"
"github.com/qdm12/ddns-updater/internal/provider/providers/noip"
@@ -120,6 +121,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, host string
return luadns.New(data, domain, host, ipVersion)
case constants.Namecheap:
return namecheap.New(data, domain, host, ipVersion)
case constants.NameCom:
return namecom.New(data, domain, host, ipVersion)
case constants.Netcup:
return netcup.New(data, domain, host, ipVersion)
case constants.Njalla:

View File

@@ -0,0 +1,65 @@
package namecom
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
func (p *Provider) createRecord(ctx context.Context, client *http.Client,
ip netip.Addr) (err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}
u := &url.URL{
Scheme: "https",
Host: "api.name.com",
Path: fmt.Sprintf("/v4/domains/%s/records", p.domain),
User: url.UserPassword(p.username, p.token),
}
postRecordsParams := struct {
Host string `json:"host"`
Type string `json:"type"`
Answer string `json:"answer"`
TTL *uint32 `json:"ttl,omitempty"`
}{
Host: p.host,
Type: recordType,
Answer: ip.String(),
TTL: p.ttl,
}
bodyBytes, err := json.Marshal(postRecordsParams)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrRequestMarshal, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrBadRequest, err)
}
setHeaders(request)
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("doing HTTP request: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK, http.StatusCreated:
return verifySuccessResponseBody(response.Body, ip)
default:
return parseErrorResponse(response)
}
}

View File

@@ -0,0 +1,64 @@
package namecom
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
func (p *Provider) getRecordID(ctx context.Context, client *http.Client,
recordType string) (recordID int, err error) {
u := &url.URL{
Scheme: "https",
Host: "api.name.com",
Path: fmt.Sprintf("/v4/domains/%s/records", p.domain),
User: url.UserPassword(p.username, p.token),
}
// by default GET request will return 1000 records.
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return 0, fmt.Errorf("%w: %w", errors.ErrBadRequest, err)
}
setHeaders(request)
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return 0, fmt.Errorf("%w", errors.ErrDomainNotFound)
default:
return 0, parseErrorResponse(response)
}
decoder := json.NewDecoder(response.Body)
var data struct {
Records []struct {
RecordID int `json:"id"`
Host string `json:"host"`
Type string `json:"type"`
} `json:"records"`
}
err = decoder.Decode(&data)
if err != nil {
return 0, fmt.Errorf("%w: %w", errors.ErrUnmarshalResponse, err)
}
for _, record := range data.Records {
if record.Host == p.host && record.Type == recordType {
return record.RecordID, nil
}
}
return 0, fmt.Errorf("%w: in %d record(s)",
errors.ErrRecordNotFound, len(data.Records))
}

View File

@@ -0,0 +1,13 @@
package namecom
import (
"net/http"
"github.com/qdm12/ddns-updater/internal/provider/headers"
)
func setHeaders(request *http.Request) {
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
headers.SetUserAgent(request)
}

View File

@@ -0,0 +1,119 @@
package namecom
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/netip"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)
type Provider struct {
domain string
host string
ipVersion ipversion.IPVersion
username string
token string
ttl *uint32
}
func New(data json.RawMessage, domain, host string,
ipVersion ipversion.IPVersion) (p *Provider, err error) {
extraSettings := struct {
Username string `json:"username"`
Token string `json:"token"`
TTL *uint32 `json:"ttl,omitempty"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}
const minTTL = 300
switch {
case extraSettings.Username == "":
return nil, fmt.Errorf("%w", errors.ErrEmptyUsername)
case extraSettings.Token == "":
return nil, fmt.Errorf("%w", errors.ErrEmptyPassword)
case extraSettings.TTL != nil && *extraSettings.TTL < minTTL:
return nil, fmt.Errorf("%w: %d must be at least %d",
errors.ErrTTLTooLow, *extraSettings.TTL, minTTL)
}
return &Provider{
domain: domain,
host: host,
ipVersion: ipVersion,
username: extraSettings.Username,
token: extraSettings.Token,
ttl: extraSettings.TTL,
}, nil
}
func (p *Provider) String() string {
return utils.ToString(p.domain, p.host, constants.NameCom, p.ipVersion)
}
func (p *Provider) Domain() string {
return p.domain
}
func (p *Provider) Host() string {
return p.host
}
func (p *Provider) IPVersion() ipversion.IPVersion {
return p.ipVersion
}
func (p *Provider) Proxied() bool {
return false
}
func (p *Provider) BuildDomainName() string {
return utils.BuildDomainName(p.host, p.domain)
}
func (p *Provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName())),
Host: models.HTML(p.Host()),
Provider: "<a href=\"https://name.com\">Name.com</a>",
IPVersion: models.HTML(p.ipVersion.String()),
}
}
func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
// Documentation at https://www.name.com/api-docs
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}
recordID, err := p.getRecordID(ctx, client, recordType)
if stderrors.Is(err, errors.ErrRecordNotFound) {
err = p.createRecord(ctx, client, ip)
if err != nil {
return netip.Addr{}, fmt.Errorf("%w: %w", errors.ErrCreateRecord, err)
}
return ip, nil
} else if err != nil {
return netip.Addr{}, fmt.Errorf("%w: %w", errors.ErrGetRecordID, err)
}
err = p.updateRecord(ctx, client, recordID, ip)
if err != nil {
return netip.Addr{}, fmt.Errorf("%w: %w", errors.ErrUpdateRecord, err)
}
return ip, nil
}

View File

@@ -0,0 +1,63 @@
package namecom
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
func parseErrorResponse(response *http.Response) (err error) {
var errorResponse struct {
Message string `json:"message"`
Details string `json:"details"`
}
decoder := json.NewDecoder(response.Body)
err = decoder.Decode(&errorResponse)
if err != nil {
return fmt.Errorf("for error response: %w: %w", errors.ErrUnmarshalResponse, err)
}
switch strings.ToLower(errorResponse.Message) {
case "not found":
return wrapErrorAndDetails(errors.ErrRecordNotFound, errorResponse.Details)
case "permission denied", "unauthenticated":
return wrapErrorAndDetails(errors.ErrAuth, errorResponse.Details)
case "invalid argument":
return wrapErrorAndDetails(errors.ErrBadRequest, errorResponse.Details)
}
return fmt.Errorf("%w: %s: %s (status code %d)", errors.ErrUnknownResponse,
errorResponse.Message, errorResponse.Details, response.StatusCode)
}
func verifySuccessResponseBody(responseBody io.ReadCloser, sentIP netip.Addr) (err error) {
decoder := json.NewDecoder(responseBody)
var responseData struct {
Answer string `json:"answer"`
}
err = decoder.Decode(&responseData)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrUnmarshalResponse, err)
}
receivedIP, err := netip.ParseAddr(responseData.Answer)
if err != nil {
return fmt.Errorf("%w: %s", errors.ErrIPReceivedMalformed, responseData.Answer)
} else if sentIP.Compare(receivedIP) != 0 {
return fmt.Errorf("%w: sent ip %s to update but received %s",
errors.ErrIPReceivedMismatch, sentIP, receivedIP)
}
return nil
}
func wrapErrorAndDetails(sentinelErr error, details string) (wrappedErr error) {
if details == "" {
return fmt.Errorf("%w", sentinelErr)
}
return fmt.Errorf("%w: %s", sentinelErr, details)
}

View File

@@ -0,0 +1,65 @@
package namecom
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
recordID int, ip netip.Addr) (err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}
u := &url.URL{
Scheme: "https",
Host: "api.name.com",
Path: fmt.Sprintf("/v4/domains/%s/records/%d", p.domain, recordID),
User: url.UserPassword(p.username, p.token),
}
postRecordsParams := struct {
Host string `json:"host"`
Type string `json:"type"`
Answer string `json:"answer"`
TTL *uint32 `json:"ttl,omitempty"`
}{
Host: p.host,
Type: recordType,
Answer: ip.String(),
TTL: p.ttl,
}
bodyBytes, err := json.Marshal(postRecordsParams)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrRequestMarshal, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrBadRequest, err)
}
setHeaders(request)
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("doing HTTP request: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK, http.StatusCreated:
return verifySuccessResponseBody(response.Body, ip)
default:
return parseErrorResponse(response)
}
}

1
readme/name.svg Normal file
View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 1510.1 240.3" viewBox="0 0 1510.1 240.3" xmlns="http://www.w3.org/2000/svg"><path d="m203.2 233.3-143.7-151.2v95.7c0 24.7 11.5 37.2 34.5 37.2v14.2h-84.3v-14.2c18.6 0 28.4-12.5 28.4-37.2v-146.8c0-7.1-11.5-11.2-33.1-11.2v-14.8h63.9l126.8 136v-87c0-23-11.8-34.2-34.2-34.2v-14.8h83.9v14.9c-20.3 0-30.8 11.2-30.8 34.2v176.2zm189.7-21c-3.7 13.2-19.3 20.6-34.5 20.6-13.2 0-27.7-5.4-32.1-19.3-12.4 13.2-25 19.3-43.3 19.3-23 0-41.3-10.5-41.3-34.8 0-30.1 28.4-46 81.5-55.1v-10.8c0-22.7-3.3-29-18.6-30.1-9.5-.7-24.7 2.4-24.7 14.9 1.7 2.4 3 6.1 3 10.5 0 9.5-6.4 14.6-17.3 14.5-9.7-.1-15.6-5.4-15.6-16.2 0-22.3 19.3-40.6 58.9-40.6 33.1 0 58.5 12.9 58.5 46v71c0 6.4 3 10.1 8.5 10.1 3.4 0 6.4-1.7 10.5-4.1zm-68.7-54.1c-25.7 4.1-37.9 11.5-37.9 35.5 0 16.2 5.7 20.3 16.2 20.3 23 0 21.6-19.7 21.6-32.1.1-12.4.1-23.7.1-23.7zm272.3 71.1v-14.3c14.2 0 19.6-4.7 19.6-14.2v-59.5c0-22.3-6.8-34.5-23.7-34.5-13.5 0-22.7 7.8-28.8 26v62.9c0 12.5 5.7 19.3 19.6 19.3v14.2h-82.2v-14.2c13.2 0 18.3-7.1 18.3-20.3v-45c0-29.8-6.8-43-25-43s-27.7 13.3-27.7 38.3v54.8c0 9.8 6.1 15.2 19.6 15.2v14.2h-82.5v-14.2c12.5 0 18.6-4.7 18.6-14.2v-83.5c0-5.1-7.4-7.8-22.3-7.8v-12.8l62.9-11.8c2 2 2.7 4.4 2.7 9.1v12.2c10.8-12.9 27.1-20.6 44.6-20.6 17.3 0 32.8 7.4 41.9 24 13.2-16.9 30.8-24 51.4-24 26.7 0 55.8 11.8 55.8 49.4v66c0 9.5 7.1 14.2 21.3 14.2v14.2h-84.1zm230.1-35.2c-13.2 26.4-31.1 41.3-62.6 41.3-41.6 0-72.4-26-72.4-73.7 0-48.7 31.8-76.4 74.1-76.4 41.3 0 63.9 26 63.9 61.2 0 2.7 0 6.1-.3 9.1h-87.6c-2.4 0-3.7 1-3.7 2.7 0 30.8 10.8 55.1 36.5 55.1 18.3 0 30.8-12.5 41.3-27.1zm-52.6-52.5c8.1 0 11.5-4.1 11.5-13.9 0-11.5-5.6-27.1-22.9-27.1-18.9 0-24.7 19.6-24.7 38.9 0 1.4 1.4 2 5.1 2h31zm273.8 57.2c-10.5 26.7-37.2 36.5-60.5 36.5-37.9 0-68-25.7-68-72.7 0-49 32.8-77.5 76.4-77.5 36.9 0 49 20.3 49 32.8 0 15.3-7.8 19.2-18.4 19.2s-18.3-6.1-17.1-17.2c.2-1.7 1.4-7.1 1.4-10.1 0-7.1-8.8-10.5-16.9-10.5-23.3 0-27.4 29.1-27.4 53.8 0 32.1 6.8 59.2 34.8 59.2 14.2 0 25.4-6.8 34.2-20.3zm88.8 36.5c-40.3 0-76.1-25.7-76.1-76.1 0-52.4 38.9-74.1 77.5-74.1 42.3 0 77.1 26 77.1 74.8-.1 51.8-39.6 75.4-78.5 75.4zm31.4-66.6c0-25-.7-68-31.1-68-28.1 0-28.8 35.9-28.8 47.7 0 28.8 0 71.7 31.5 71.7 28.1 0 28.4-33.8 28.4-51.4zm252.9 60.6v-14.3c14.2 0 19.6-4.7 19.6-14.2v-59.5c0-22.3-6.8-34.5-23.7-34.5-13.5 0-22.7 7.8-28.8 26v62.9c0 12.5 5.8 19.3 19.6 19.3v14.2h-82.2v-14.2c13.2 0 18.3-7.1 18.3-20.3v-45c0-29.8-6.8-43-25-43-18.3 0-27.7 13.2-27.7 38.2v54.8c0 9.8 6.1 15.2 19.6 15.2v14.2h-82.5v-14.1c12.5 0 18.6-4.7 18.6-14.2v-83.5c0-5.1-7.4-7.8-22.3-7.8v-12.8l62.9-11.8c2 2 2.7 4.4 2.7 9.1v12.2c10.8-12.9 27.1-20.6 44.6-20.6 17.3 0 32.8 7.4 41.9 24 13.2-16.9 30.8-24 51.4-24 26.7 0 55.8 11.8 55.8 49.4v66c0 9.5 7.1 14.2 21.3 14.2v14.2h-84.1z" fill="#fff"/><circle cx="873.9" cy="208.7" fill="#6eda78" r="25.3"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB