feat(providers): namesilo.com support (#866)

- Credits to @Zeustopher for writing most of the readme
This commit is contained in:
hyperring
2024-12-24 20:18:18 +11:00
committed by GitHub
parent 03154c35f8
commit 78f30614b1
11 changed files with 380 additions and 0 deletions

View File

@@ -32,6 +32,9 @@
},
{
"pattern": "^https://my.vultr.com/settings/#settingsapi$"
},
{
"pattern": "^https://www.namesilo.com"
}
],
"timeout": "20s",

View File

@@ -81,6 +81,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- Myaddr
- Name.com
- Namecheap
- NameSilo
- Netcup
- NoIP
- Now-DNS
@@ -247,6 +248,7 @@ Check the documentation for your DNS provider:
- [Myaddr](docs/myaddr.md)
- [Name.com](docs/name.com.md)
- [Namecheap](docs/namecheap.md)
- [NameSilo](docs/namesilo.md)
- [Netcup](docs/netcup.md)
- [NoIP](docs/noip.md)
- [Now-DNS](docs/nowdns.md)

51
docs/namesilo.md Normal file
View File

@@ -0,0 +1,51 @@
# NameSilo
[![NameSilo Website](../readme/namesilo.jpg)](https://www.namesilo.com)
## Configuration
### Example
```json
{
"settings": [
{
"provider": "namesilo",
"domain": "sub.example.com",
"key": "71dZaE8c2Aa926Ca2E8c1",
"ttl": 7207,
"ip_version": "ipv4"
}
]
}
```
### Compulsory parameters
- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
- `"key"` is the NameSilo API Key obtained using the domain setup instructions below. For example: `71dZaE8c2Aa926Ca2E8c1`.
### Optional parameters
- `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
- `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
- `"ttl"` is the record's Time to Live (TTL), which defaults to `7207` seconds. It must be numeric, less than `2592001`, and greater than or equal to `3600`. TTL values of `3603` or `7207` may be subject to NameSilo's [Automatic TTL Adjustments](https://www.namesilo.com/support/v2/articles/domain-manager/dns-manager#auto_ttl).
## Domain setup
1. Login to the [Namesilo API Manager](https://www.namesilo.com/account/api-manager) with your account credentials.
1. Generate an API key. The generated API key will look similar to `71dZaE8c2Aa926Ca2E8c1`.
- (do _not_ check the "Generate key for read-only access" box)
[![Before NameSilo API Key](../readme/namesilo1.jpg)](https://www.namesilo.com/account/api-manager)
[![After NameSilo API Key](../readme/namesilo2.jpg)](https://www.namesilo.com/account/api-manager)
## Testing
1. Go to the [NameSilo Domain Manager](https://www.namesilo.com/account_domains.php).
1. Choose "Manage DNS for this domain" (the globe icon) for the domain you wish to test.
[![Manage DNS for this domain](../readme/namesilo3.jpg)](https://www.namesilo.com/account_domains.php)
1. Change the IP address of the host to `127.0.0.1`.
1. Run the ddns-updater.
1. Refresh the Namesilo webpage to check the update occurred.

View File

@@ -40,6 +40,7 @@ const (
Myaddr models.Provider = "myaddr"
Namecheap models.Provider = "namecheap"
NameCom models.Provider = "name.com"
NameSilo models.Provider = "namesilo"
Netcup models.Provider = "netcup"
Njalla models.Provider = "njalla"
NoIP models.Provider = "noip"
@@ -94,6 +95,7 @@ func ProviderChoices() []models.Provider {
Myaddr,
Namecheap,
NameCom,
NameSilo,
Njalla,
NoIP,
NowDNS,

View File

@@ -30,6 +30,7 @@ var (
ErrTokenNotValid = errors.New("token is not valid")
ErrTTLNotSet = errors.New("TTL is not set")
ErrTTLTooLow = errors.New("TTL is too low")
ErrTTLTooHigh = errors.New("TTL is too high")
ErrURLNotHTTPS = errors.New("url is not https")
ErrURLNotSet = errors.New("url is not set")
ErrUsernameNotSet = errors.New("username is not set")

View File

@@ -46,6 +46,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/myaddr"
"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/namesilo"
"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"
@@ -155,6 +156,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return namecheap.New(data, domain, owner)
case constants.NameCom:
return namecom.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.NameSilo:
return namesilo.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Netcup:
return netcup.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Njalla:

View File

@@ -0,0 +1,318 @@
package namesilo
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strconv"
"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/headers"
"github.com/qdm12/ddns-updater/internal/provider/utils"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)
type Provider struct {
domain string
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
key string
ttl *uint32
}
type apiResponse struct {
Reply struct {
Code json.Number `json:"code"`
Detail string `json:"detail"`
Records []struct {
ID string `json:"record_id"`
Type string `json:"type"`
Host string `json:"host"`
Value string `json:"value"`
} `json:"resource_record,omitempty"` // Field only available during list record
} `json:"reply"`
}
func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
provider *Provider, err error,
) {
var providerSpecificSettings struct {
Key string `json:"key"`
TTL *uint32 `json:"ttl,omitempty"`
}
err = json.Unmarshal(data, &providerSpecificSettings)
if err != nil {
return nil, fmt.Errorf("json decoding provider specific settings: %w", err)
}
err = validateSettings(domain, providerSpecificSettings.Key, providerSpecificSettings.TTL)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}
return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
key: providerSpecificSettings.Key,
ttl: providerSpecificSettings.TTL,
}, nil
}
func validateSettings(domain, key string, ttl *uint32) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}
const (
minTTL = uint32(3600)
maxTTL = uint32(2592001)
)
switch {
case key == "":
return fmt.Errorf("%w", errors.ErrAPIKeyNotSet)
case ttl != nil && *ttl < minTTL:
return fmt.Errorf("%w: %d must be at least %d", errors.ErrTTLTooLow, *ttl, minTTL)
case ttl != nil && *ttl > maxTTL:
return fmt.Errorf("%w: %d must be at most %d", errors.ErrTTLTooHigh, *ttl, maxTTL)
}
return nil
}
func (p *Provider) String() string {
return utils.ToString(p.domain, p.owner, constants.NameSilo, p.ipVersion)
}
func (p *Provider) Domain() string {
return p.domain
}
func (p *Provider) Owner() string {
return p.owner
}
func (p *Provider) IPVersion() ipversion.IPVersion {
return p.ipVersion
}
func (p *Provider) IPv6Suffix() netip.Prefix {
return p.ipv6Suffix
}
func (p *Provider) Proxied() bool {
return false
}
func (p *Provider) BuildDomainName() string {
return utils.BuildDomainName(p.owner, p.domain)
}
func (p *Provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Owner: p.Owner(),
Provider: "<a href=\"https://www.namesilo.com/\">NameSilo</a>",
IPVersion: p.ipVersion.String(),
}
}
// Update does the following:
// 1. if there's no record, create it.
// 2. if it exists and ip is different, update it.
// 3. if it exists and ip is the same, do nothing.
func (p *Provider) Update(ctx context.Context, client *http.Client, newIP netip.Addr) (netip.Addr, error) {
recordType := constants.A
if newIP.Is6() {
recordType = constants.AAAA
}
recordID, currentIP, err := p.getRecord(ctx, client, recordType)
if stderrors.Is(err, errors.ErrRecordNotFound) {
if err := p.createRecord(ctx, client, recordType, newIP); err != nil {
return netip.Addr{}, fmt.Errorf("creating record: %w", err)
}
return newIP, nil
} else if err != nil {
return netip.Addr{}, fmt.Errorf("retrieving records: %w", err)
}
if currentIP != newIP {
if err := p.updateRecord(ctx, client, recordID, newIP); err != nil {
return netip.Addr{}, fmt.Errorf("updating record: %w", err)
}
}
return newIP, nil
}
// https://www.namesilo.com/api-reference#dns/dns-list-records
func (p *Provider) getRecord(ctx context.Context, client *http.Client, recordType string) (
id *string, ip netip.Addr, err error,
) {
queryParams := url.Values{}
url := p.createRequestURL("/api/dnsListRecords", queryParams)
response, err := p.sendAPIRequest(ctx, client, url)
if err != nil {
return nil, netip.Addr{}, err
}
// find matching record
host := utils.BuildURLQueryHostname(p.owner, p.domain)
for _, record := range response.Reply.Records {
if record.Host != host || record.Type != recordType {
continue
}
ip, err = netip.ParseAddr(record.Value)
if err != nil {
return nil, netip.Addr{}, fmt.Errorf("parsing existing IP: %w", err)
}
return &record.ID, ip, nil
}
return nil, netip.Addr{}, fmt.Errorf("%w", errors.ErrRecordNotFound)
}
// https://www.namesilo.com/api-reference#dns/dns-add-record
func (p *Provider) createRecord(
ctx context.Context,
client *http.Client,
recordType string,
ip netip.Addr,
) error {
const path = "/api/dnsAddRecord"
queryParams := p.buildRecordParams(ip)
queryParams.Set("rrtype", recordType)
url := p.createRequestURL(path, queryParams)
_, err := p.sendAPIRequest(ctx, client, url)
return err
}
// https://www.namesilo.com/api-reference#dns/dns-update-record
func (p *Provider) updateRecord(
ctx context.Context,
client *http.Client,
recordID *string,
ip netip.Addr,
) error {
const path = "/api/dnsUpdateRecord"
queryParams := p.buildRecordParams(ip)
queryParams.Set("rrid", *recordID)
url := p.createRequestURL(path, queryParams)
_, err := p.sendAPIRequest(ctx, client, url)
return err
}
// Create and populate common query params for requests that modify a single record (ie. add or update).
func (p *Provider) buildRecordParams(ip netip.Addr) url.Values {
name := p.owner
if name == "@" {
name = ""
}
queryParams := url.Values{
"rrhost": {name},
"rrvalue": {ip.String()},
}
if p.ttl != nil {
queryParams.Set("rrttl", strconv.FormatUint(uint64(*p.ttl), 10))
}
return queryParams
}
func (p *Provider) createRequestURL(path string, queryParams url.Values) string {
baseURL := url.URL{
Scheme: "https",
Host: "www.namesilo.com",
Path: path,
}
queryParams.Set("version", "1")
queryParams.Set("type", "json")
queryParams.Set("key", p.key)
queryParams.Set("domain", p.domain)
baseURL.RawQuery = queryParams.Encode()
return baseURL.String()
}
func (p *Provider) sendAPIRequest(ctx context.Context, client *http.Client, url string) (*apiResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating http request: %w", err)
}
headers.SetUserAgent(request)
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(string(data)))
}
var parsedResponse apiResponse
err = json.Unmarshal(data, &parsedResponse)
if err != nil {
return nil, fmt.Errorf("json decoding response body: %w", err)
}
err = p.validateResponseCode(parsedResponse.Reply.Code, parsedResponse.Reply.Detail)
if err != nil {
return nil, fmt.Errorf("validating reply code: %w", err)
}
return &parsedResponse, nil
}
// https://www.namesilo.com/api-reference (Response Codes)
func (p *Provider) validateResponseCode(code json.Number, detail string) error {
// The API inconsistently swaps between number and string typing for the code field,
// but the value should always be an integer.
parsedCode, err := code.Int64()
if err != nil {
return fmt.Errorf("parsing response code: %w", err)
}
codeToError := map[int64]error{
300: nil, // Successful API operation
110: errors.ErrKeyNotValid, // Invalid API key
112: errors.ErrFeatureUnavailable, // API not available to Sub-Accounts
113: errors.ErrBannedAbuse, // API account cannot be accessed from your IP
200: errors.ErrDomainDisabled, // Domain is not active, or does not belong to this user
201: errors.ErrDNSServerSide, // Internal system error
210: errors.ErrUnsuccessful, // General error (details in response)
280: errors.ErrBadRequest, // DNS modification error
}
if err, exists := codeToError[parsedCode]; exists {
if err == nil {
return nil // Successful operation, no error to return
}
return fmt.Errorf("%w: %d: %s", err, parsedCode, detail)
}
return fmt.Errorf("%w: %d: %s", errors.ErrUnknownResponse, parsedCode, detail)
}

BIN
readme/namesilo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
readme/namesilo1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
readme/namesilo2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
readme/namesilo3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB