feat(custom): add custom provider

- Sends HTTP GET request to url given with ip information
- Configurable ipv4 and ipv6 query parameter keys
- Configurable response success detection with a regex
- Treat non status OK 200 responses as failures
This commit is contained in:
Quentin McGaw
2024-01-19 19:54:43 +00:00
parent 12c46e7635
commit 0c561d4378
6 changed files with 206 additions and 0 deletions

View File

@@ -173,6 +173,7 @@ Check the documentation for your DNS provider:
- [Aliyun](https://github.com/qdm12/ddns-updater/blob/master/docs/aliyun.md)
- [Cloudflare](https://github.com/qdm12/ddns-updater/blob/master/docs/cloudflare.md)
- [Custom](https://github.com/qdm12/ddns-updater/blob/master/docs/custom.md)
- [DDNSS.de](https://github.com/qdm12/ddns-updater/blob/master/docs/ddnss.de.md)
- [deSEC](https://github.com/qdm12/ddns-updater/blob/master/docs/desec.md)
- [DigitalOcean](https://github.com/qdm12/ddns-updater/blob/master/docs/digitalocean.md)

40
docs/custom.md Normal file
View File

@@ -0,0 +1,40 @@
# Custom provider
The custom provider allows to configure a URL with a few additional parameters to update your records.
For now it sends an HTTP GET request to the URL given with some additional parameters.
Feel free to open issues to extend its configuration options.
## Configuration
### Example
```json
{
"settings": [
{
"provider": "custom",
"domain": "example.com",
"host": "@",
"url": "https://example.com/update?domain=example.com&host=@&username=username&client_key=client_key",
"ipv4key": "ipv4",
"ipv6key": "ipv6",
"success_regex": "good",
"ip_version": "ipv4"
}
]
}
```
### Compulsory parameters
- `"domain"` is the domain name to update
- `"host"` is the host to update, which can be `"@"` (root), `"*"` or a subdomain
- `"url"` is the URL to update your records and should contain all the information EXCEPT the IP address to update
- `"ipv4key"` is the URL query parameter name for the IPv4 address, for example `ipv4` will be added to the URL with `&ipv4=1.2.3.4`.
- `"ipv6key"` is the URL query parameter name for the IPv6 address, for example `ipv6` will be added to the URL with `&ipv6=::aaff`. Even if you don't use IPv6, this must be set to something.
- `"success_regex"` is a regular expression to match the response from the server to determine if the update was successful. You can use [regex101.com](https://regex101.com/) to find the regular expression you want. For example `good` would match any response containing the word "good".
### Optional parameters
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`

View File

@@ -7,6 +7,7 @@ const (
Aliyun models.Provider = "aliyun"
AllInkl models.Provider = "allinkl"
Cloudflare models.Provider = "cloudflare"
Custom models.Provider = "custom"
Dd24 models.Provider = "dd24"
DdnssDe models.Provider = "ddnss"
DeSEC models.Provider = "desec"

View File

@@ -17,6 +17,8 @@ var (
ErrHostOnlyAt = errors.New(`host can only be "@"`)
ErrHostOnlySubdomain = errors.New("host can only be a subdomain")
ErrHostWildcard = errors.New(`host cannot be a "*"`)
ErrIPv4KeyNotSet = errors.New("IPv4 key is not set")
ErrIPv6KeyNotSet = errors.New("IPv6 key is not set")
ErrIPv6NotSupported = errors.New("IPv6 is not supported by this provider")
ErrKeyNotSet = errors.New("key is not set")
ErrKeyNotValid = errors.New("key is not valid")
@@ -24,10 +26,13 @@ var (
ErrPasswordNotSet = errors.New("password is not set")
ErrPasswordNotValid = errors.New("password is not valid")
ErrSecretNotSet = errors.New("secret is not set")
ErrSuccessRegexNotSet = errors.New("success regex is not set")
ErrTokenNotSet = errors.New("token is not set")
ErrTokenNotValid = errors.New("token is not valid")
ErrTTLNotSet = errors.New("TTL is not set")
ErrTTLTooLow = errors.New("TTL is too low")
ErrURLNotHTTPS = errors.New("url is not https")
ErrURLNotSet = errors.New("url is not set")
ErrUsernameNotSet = errors.New("username is not set")
ErrUsernameNotValid = errors.New("username is not valid")
ErrUserServiceKeyNotValid = errors.New("user service key is not valid")

View File

@@ -13,6 +13,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/aliyun"
"github.com/qdm12/ddns-updater/internal/provider/providers/allinkl"
"github.com/qdm12/ddns-updater/internal/provider/providers/cloudflare"
"github.com/qdm12/ddns-updater/internal/provider/providers/custom"
"github.com/qdm12/ddns-updater/internal/provider/providers/dd24"
"github.com/qdm12/ddns-updater/internal/provider/providers/ddnss"
"github.com/qdm12/ddns-updater/internal/provider/providers/desec"
@@ -79,6 +80,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, host string
return allinkl.New(data, domain, host, ipVersion)
case constants.Cloudflare:
return cloudflare.New(data, domain, host, ipVersion)
case constants.Custom:
return custom.New(data, domain, host, ipVersion)
case constants.Dd24:
return dd24.New(data, domain, host, ipVersion)
case constants.DdnssDe:

View File

@@ -0,0 +1,156 @@
package custom
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"regexp"
"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
host string
ipVersion ipversion.IPVersion
url *url.URL
ipv4Key string
ipv6Key string
successRegex regexp.Regexp
}
func New(data json.RawMessage, domain, host string,
ipVersion ipversion.IPVersion) (p *Provider, err error) {
extraSettings := struct {
URL string `json:"url"`
IPv4Key string `json:"ipv4key"`
IPv6Key string `json:"ipv6key"`
SuccessRegex regexp.Regexp `json:"success_regex"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, fmt.Errorf("JSON decoding provider specific settings: %w", err)
}
parsedURL, err := url.Parse(extraSettings.URL)
if err != nil {
return nil, fmt.Errorf("parsing URL: %w", err)
}
p = &Provider{
domain: domain,
host: host,
ipVersion: ipVersion,
url: parsedURL,
ipv4Key: extraSettings.IPv4Key,
ipv6Key: extraSettings.IPv6Key,
successRegex: extraSettings.SuccessRegex,
}
err = p.isValid()
if err != nil {
return nil, err
}
return p, nil
}
func (p *Provider) isValid() error {
switch {
case p.url.String() == "":
return fmt.Errorf("%w", errors.ErrURLNotSet)
case p.url.Scheme != "https":
return fmt.Errorf("%w: %s", errors.ErrURLNotHTTPS, p.url.Scheme)
case p.ipv4Key == "":
return fmt.Errorf("%w", errors.ErrIPv4KeyNotSet)
case p.ipv6Key == "":
return fmt.Errorf("%w", errors.ErrIPv6KeyNotSet)
case p.successRegex.String() == "":
return fmt.Errorf("%w", errors.ErrSuccessRegexNotSet)
default:
return nil
}
}
func (p *Provider) String() string {
return utils.ToString(p.domain, p.host, constants.Custom, 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 {
updateHostname := p.url.Hostname()
return models.HTMLRow{
Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Host: p.Host(),
Provider: fmt.Sprintf("<a href=\"https://%s/\">%s: %s</a>",
updateHostname, constants.Custom, updateHostname),
IPVersion: p.ipVersion.String(),
}
}
func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
values := url.Values{}
values.Set("hostname", utils.BuildURLQueryHostname(p.host, p.domain))
ipKey := p.ipv4Key
if ip.Is6() {
ipKey = p.ipv6Key
}
values.Set(ipKey, ip.String())
p.url.RawQuery = values.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url.String(), nil)
if err != nil {
return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
}
headers.SetUserAgent(request)
response, err := client.Do(request)
if err != nil {
return netip.Addr{}, err
}
defer response.Body.Close()
b, err := io.ReadAll(response.Body)
if err != nil {
return netip.Addr{}, fmt.Errorf("reading response body: %w", err)
}
s := string(b)
if response.StatusCode != http.StatusOK {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s))
}
if p.successRegex.MatchString(s) {
return ip, nil
}
return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnknownResponse,
utils.ToSingleLine(s))
}