Linode support (#144)

This commit is contained in:
Quentin McGaw
2021-01-20 18:31:19 -05:00
committed by GitHub
parent 43e168581c
commit 180092f47e
8 changed files with 409 additions and 1 deletions

View File

@@ -31,6 +31,7 @@
- Google - Google
- He.net - He.net
- Infomaniak - Infomaniak
- Linode
- LuaDNS - LuaDNS
- Namecheap - Namecheap
- NoIP - NoIP
@@ -138,6 +139,7 @@ Check the documentation for your DNS provider:
- [Google](docs/google.md) - [Google](docs/google.md)
- [He.net](docs/he.net.md) - [He.net](docs/he.net.md)
- [Infomaniak](docs/infomaniak.md) - [Infomaniak](docs/infomaniak.md)
- [Linode](docs/linode.md)
- [LuaDNS](docs/luadns.md) - [LuaDNS](docs/luadns.md)
- [Namecheap](docs/namecheap.md) - [Namecheap](docs/namecheap.md)
- [NoIP](docs/noip.md) - [NoIP](docs/noip.md)

34
docs/linode.md Normal file
View File

@@ -0,0 +1,34 @@
# Linode
## Configuration
### Example
```json
{
"settings": [
{
"provider": "linode",
"domain": "domain.com",
"host": "@",
"token": "token",
"ip_version": "ipv4"
}
]
}
```
### Compulsory parameters
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"` or `"*"`
- `"token"`
### Optional parameters
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
## Domain setup
1. Create a personal access token with `domains` set, with read and write privileges, ideally that never expires. You can refer to [@AnujRNair's comment](https://github.com/qdm12/ddns-updater/pull/144#discussion_r559292678) and to [Linode's guide](https://www.linode.com/docs/products/tools/cloud-manager/guides/cloud-api-keys).
1. The program will create the A or AAAA record for you if it doesn't exist already.

View File

@@ -18,6 +18,7 @@ const (
GOOGLE models.Provider = "google" GOOGLE models.Provider = "google"
HE models.Provider = "he" HE models.Provider = "he"
INFOMANIAK models.Provider = "infomaniak" INFOMANIAK models.Provider = "infomaniak"
LINODE models.Provider = "linode"
LUADNS models.Provider = "luadns" LUADNS models.Provider = "luadns"
NAMECHEAP models.Provider = "namecheap" NAMECHEAP models.Provider = "namecheap"
NOIP models.Provider = "noip" NOIP models.Provider = "noip"
@@ -43,6 +44,7 @@ func ProviderChoices() []models.Provider {
GOOGLE, GOOGLE,
HE, HE,
INFOMANIAK, INFOMANIAK,
LINODE,
LUADNS, LUADNS,
NAMECHEAP, NAMECHEAP,
NOIP, NOIP,

View File

@@ -136,6 +136,8 @@ func makeSettingsFromObject(common commonSettings, rawSettings json.RawMessage,
settingsConstructor = settings.NewHe settingsConstructor = settings.NewHe
case constants.INFOMANIAK: case constants.INFOMANIAK:
settingsConstructor = settings.NewInfomaniak settingsConstructor = settings.NewInfomaniak
case constants.LINODE:
settingsConstructor = settings.NewLinode
case constants.LUADNS: case constants.LUADNS:
settingsConstructor = settings.NewLuaDNS settingsConstructor = settings.NewLuaDNS
case constants.NAMECHEAP: case constants.NAMECHEAP:

View File

@@ -29,6 +29,7 @@ var (
// Intermediary steps errors. // Intermediary steps errors.
var ( var (
ErrCreateRecord = errors.New("cannot create record") ErrCreateRecord = errors.New("cannot create record")
ErrGetDomainID = errors.New("cannot get domain ID")
ErrGetRecordID = errors.New("cannot get record ID") ErrGetRecordID = errors.New("cannot get record ID")
ErrGetRecordInZone = errors.New("cannot get record in zone") // LuaDNS ErrGetRecordInZone = errors.New("cannot get record in zone") // LuaDNS
ErrGetZoneID = errors.New("cannot get zone ID") // LuaDNS ErrGetZoneID = errors.New("cannot get zone ID") // LuaDNS
@@ -48,6 +49,7 @@ var (
ErrBannedUserAgent = errors.New("user agend is banned") ErrBannedUserAgent = errors.New("user agend is banned")
ErrConflictingRecord = errors.New("conflicting record") ErrConflictingRecord = errors.New("conflicting record")
ErrDNSServerSide = errors.New("server side DNS error") ErrDNSServerSide = errors.New("server side DNS error")
ErrDomainDisabled = errors.New("record disabled")
ErrDomainIDNotFound = errors.New("ID not found in domain record") ErrDomainIDNotFound = errors.New("ID not found in domain record")
ErrFeatureUnavailable = errors.New("feature is not available to the user") ErrFeatureUnavailable = errors.New("feature is not available to the user")
ErrHostnameNotExists = errors.New("hostname does not exist") ErrHostnameNotExists = errors.New("hostname does not exist")
@@ -61,6 +63,7 @@ var (
ErrPrivateIPSent = errors.New("private IP cannot be routed") ErrPrivateIPSent = errors.New("private IP cannot be routed")
ErrRecordNotEditable = errors.New("record is not editable") // Dreamhost ErrRecordNotEditable = errors.New("record is not editable") // Dreamhost
ErrRecordNotFound = errors.New("record not found") ErrRecordNotFound = errors.New("record not found")
ErrRequestMarshal = errors.New("cannot marshal request body")
ErrUnknownResponse = errors.New("unknown response received") ErrUnknownResponse = errors.New("unknown response received")
ErrUnmarshalResponse = errors.New("cannot unmarshal update response") ErrUnmarshalResponse = errors.New("cannot unmarshal update response")
ErrUnsuccessfulResponse = errors.New("unsuccessful response") ErrUnsuccessfulResponse = errors.New("unsuccessful response")

View File

@@ -21,3 +21,11 @@ func setAuthBearer(request *http.Request, token string) {
func setAuthSSOKey(request *http.Request, key, secret string) { func setAuthSSOKey(request *http.Request, key, secret string) {
request.Header.Set("Authorization", "sso-key "+key+":"+secret) request.Header.Set("Authorization", "sso-key "+key+":"+secret)
} }
func setOauth(request *http.Request, value string) {
request.Header.Set("oauth", value)
}
func setXFilter(request *http.Request, value string) {
request.Header.Set("X-Filter", value)
}

357
internal/settings/linode.go Normal file
View File

@@ -0,0 +1,357 @@
package settings
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
)
type linode struct {
domain string
host string
ipVersion models.IPVersion
token string
}
func NewLinode(data json.RawMessage, domain, host string, ipVersion models.IPVersion,
_ bool, _ regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Token string `json:"token"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
l := &linode{
domain: domain,
host: host,
ipVersion: ipVersion,
token: extraSettings.Token,
}
if err := l.isValid(); err != nil {
return nil, err
}
return l, nil
}
func (l *linode) isValid() error {
if len(l.token) == 0 {
return ErrEmptyToken
}
return nil
}
func (l *linode) String() string {
return toString(l.domain, l.host, constants.LINODE, l.ipVersion)
}
func (l *linode) Domain() string {
return l.domain
}
func (l *linode) Host() string {
return l.host
}
func (l *linode) DNSLookup() bool {
return true
}
func (l *linode) IPVersion() models.IPVersion {
return l.ipVersion
}
func (l *linode) BuildDomainName() string {
return buildDomainName(l.host, l.domain)
}
func (l *linode) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", l.BuildDomainName(), l.BuildDomainName())),
Host: models.HTML(l.Host()),
Provider: "<a href=\"https://cloud.linode.com/\">Linode</a>",
IPVersion: models.HTML(l.ipVersion),
}
}
// Using https://www.linode.com/docs/api/domains/
func (l *linode) Update(ctx context.Context, client *http.Client, ip net.IP) (newIP net.IP, err error) {
domainID, err := l.getDomainID(ctx, client)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrGetDomainID, err)
}
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
recordID, err := l.getRecordID(ctx, client, domainID, recordType)
if errors.Is(err, ErrNotFound) {
err := l.createRecord(ctx, client, domainID, recordType, ip)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrCreateRecord, err)
}
return ip, nil
} else if err != nil {
return nil, fmt.Errorf("%w: %s", ErrGetRecordID, err)
}
if err := l.updateRecord(ctx, client, domainID, recordID, ip); err != nil {
return nil, fmt.Errorf("%w: %s", ErrUpdateRecord, err)
}
return ip, nil
}
type linodeError struct {
Field string `json:"field"`
Reason string `json:"reason"`
}
func (l *linode) setHeaders(request *http.Request) {
setUserAgent(request)
setContentType(request, "application/json")
setAuthBearer(request, l.token)
}
func (l *linode) getDomainID(ctx context.Context, client *http.Client) (domainID int, err error) {
u := url.URL{
Scheme: "https",
Host: "api.linode.com",
Path: "/v4/domains",
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return 0, err
}
l.setHeaders(request)
setOauth(request, "domains:read_only")
setXFilter(request, `{"domain": "`+l.domain+`"}`)
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %d", ErrBadHTTPStatus, response.StatusCode)
return 0, fmt.Errorf("%w: %s", err, l.getError(response.Body))
}
decoder := json.NewDecoder(response.Body)
var obj struct {
Data []struct {
ID *int `json:"id,omitempty"`
Type string `json:"type"`
Status string `json:"status"`
} `json:"data"`
}
if err := decoder.Decode(&obj); err != nil {
return 0, err
}
domains := obj.Data
switch len(domains) {
case 0:
return 0, ErrNotFound
case 1:
default:
return 0, fmt.Errorf("%w: %d records instead of 1",
ErrNumberOfResultsReceived, len(domains))
}
if domains[0].Status == "disabled" {
return 0, ErrDomainDisabled
}
if domains[0].ID == nil {
return 0, ErrDomainIDNotFound
}
return *domains[0].ID, nil
}
func (l *linode) getRecordID(ctx context.Context, client *http.Client,
domainID int, recordType string) (recordID int, err error) {
u := url.URL{
Scheme: "https",
Host: "api.linode.com",
Path: "/v4/domains/" + strconv.Itoa(domainID) + "/records",
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return 0, err
}
l.setHeaders(request)
setOauth(request, "domains:read_only")
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %d", ErrBadHTTPStatus, response.StatusCode)
return 0, fmt.Errorf("%w: %s", err, l.getError(response.Body))
}
decoder := json.NewDecoder(response.Body)
var obj struct {
Data []struct {
ID int `json:"id"`
Host string `json:"name"`
Type string `json:"type"`
} `json:"data"`
}
if err := decoder.Decode(&obj); err != nil {
return 0, fmt.Errorf("%w: %s", ErrUnmarshalResponse, err)
}
for _, domainRecord := range obj.Data {
if domainRecord.Type == recordType && domainRecord.Host == l.host {
return domainRecord.ID, nil
}
}
return 0, ErrNotFound
}
func (l *linode) createRecord(ctx context.Context, client *http.Client,
domainID int, recordType string, ip net.IP) (err error) {
u := url.URL{
Scheme: "https",
Host: "api.linode.com",
Path: "/v4/domains/" + strconv.Itoa(domainID) + "/records",
}
type domainRecord struct {
Type string `json:"type"`
Host string `json:"name"`
IP string `json:"target"`
}
requestData := domainRecord{
Type: recordType,
Host: l.host,
IP: ip.String(),
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(requestData); err != nil {
return fmt.Errorf("%w: %s", ErrRequestMarshal, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
if err != nil {
return err
}
l.setHeaders(request)
setOauth(request, "domains:read_write")
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %d", ErrBadHTTPStatus, response.StatusCode)
return fmt.Errorf("%w: %s", err, l.getError(response.Body))
}
var responseData domainRecord
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&responseData); err != nil {
return fmt.Errorf("%w: %s", ErrUnmarshalResponse, err)
}
newIP := net.ParseIP(responseData.IP)
if newIP == nil {
return fmt.Errorf("%w: %s", ErrIPReceivedMalformed, responseData.IP)
} else if !newIP.Equal(ip) {
return fmt.Errorf("%w: %s", ErrIPReceivedMismatch, newIP.String())
}
return nil
}
func (l *linode) updateRecord(ctx context.Context, client *http.Client,
domainID, recordID int, ip net.IP) (err error) {
u := url.URL{
Scheme: "https",
Host: "api.linode.com",
Path: "/v4/domains/" + strconv.Itoa(domainID) + "/records/" + strconv.Itoa(recordID),
}
data := struct {
IP string `json:"target"`
}{
IP: ip.String(),
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(data); err != nil {
return fmt.Errorf("%w: %s", ErrRequestMarshal, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), buffer)
if err != nil {
return err
}
l.setHeaders(request)
setOauth(request, "domains:read_write")
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %d", ErrBadHTTPStatus, response.StatusCode)
return fmt.Errorf("%w: %s", err, l.getError(response.Body))
}
data.IP = ""
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&data); err != nil {
return fmt.Errorf("%w: %s", ErrUnmarshalResponse, err)
}
newIP := net.ParseIP(data.IP)
if newIP == nil {
return fmt.Errorf("%w: %s", ErrIPReceivedMalformed, data.IP)
} else if !newIP.Equal(ip) {
return fmt.Errorf("%w: %s", ErrIPReceivedMismatch, newIP.String())
}
return nil
}
func (l *linode) getError(body io.Reader) (err error) {
var errorObj linodeError
b, err := ioutil.ReadAll(body)
if err != nil {
return err
}
if err := json.Unmarshal(b, &errorObj); err != nil {
return fmt.Errorf("%s", bodyDataToSingleLine(string(b)))
}
return fmt.Errorf("%s: %s", errorObj.Field, errorObj.Reason)
}

View File

@@ -43,7 +43,7 @@ func toString(domain, host string, provider models.Provider, ipVersion models.IP
return fmt.Sprintf("[domain: %s | host: %s | provider: %s | ip: %s]", domain, host, provider, ipVersion) return fmt.Sprintf("[domain: %s | host: %s | provider: %s | ip: %s]", domain, host, provider, ipVersion)
} }
func bodyToSingleLine(body io.ReadCloser) (s string) { func bodyToSingleLine(body io.Reader) (s string) {
b, err := ioutil.ReadAll(body) b, err := ioutil.ReadAll(body)
if err != nil { if err != nil {
return "" return ""