Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- Dynu
- DynV6
- EasyDNS
- FENO
- FreeDNS
- Gandi
- GCP
Expand Down Expand Up @@ -242,6 +243,7 @@ Check the documentation for your DNS provider:
- [Dynu](docs/dynu.md)
- [DynV6](docs/dynv6.md)
- [EasyDNS](docs/easydns.md)
- [FENO](docs/feno.md)
- [FreeDNS](docs/freedns.md)
- [Gandi](docs/gandi.md)
- [GCP](docs/gcp.md)
Expand Down
39 changes: 39 additions & 0 deletions docs/feno.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# FENO

## Configuration

### Example

```json
{
"settings": [
{
"provider": "feno",
"domain": "example.no",
"owner": "home",
"api_key": "feno_live_YOUR_API_KEY",
"ip_version": "ipv4",
"ipv6_suffix": ""
}
]
}
```

### Compulsory parameters

- `"domain"` is the domain to update. It can be `example.no` (root domain) or `sub.example.no` (subdomain of `example.no`). Wildcards (`*.example.no`) are not supported.
- `"api_key"` is a FENO API key (`feno_live_...`) with the `ddns:write` scope (a key with `dns:write` also works).

### Optional parameters

- `"owner"` is the sub domain to use. It can be `@` for the root domain (default), or a name such as `home` or `nas.home` for any depth of subdomain.
- `"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 temporary IPv6 address of the machine is used in the record updating. You might want to set this to use your permanent IPv6 address instead of your temporary IPv6 address.

## Domain setup

1. Make sure your domain uses the FENO nameservers (`ns1.feno.no` and `ns2.feno.no`); the update endpoint only works for zones hosted there.
1. In the [FENO dashboard](https://feno.no/), go to **Account → API keys** and create an API key with the `ddns:write` scope (MFA is required). Do not set an IP allowlist on the key, since your address is expected to change.
1. Set the key as `"api_key"`. The `A` and/or `AAAA` record is created automatically on the first update if it does not already exist.

Updates are rate limited to 10 per minute per key. More information is available in the [FENO dynamic DNS documentation](https://github.com/mrerikcodes/feno-api/blob/main/docs/DDNS.md).
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
DynV6 models.Provider = "dynv6"
EasyDNS models.Provider = "easydns"
Example models.Provider = "example"
Feno models.Provider = "feno"
FreeDNS models.Provider = "freedns"
Gandi models.Provider = "gandi"
GCP models.Provider = "gcp"
Expand Down Expand Up @@ -88,6 +89,7 @@ func ProviderChoices() []models.Provider {
DynV6,
EasyDNS,
Example,
Feno,
FreeDNS,
Gandi,
GCP,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/dynv6"
"github.com/qdm12/ddns-updater/internal/provider/providers/easydns"
"github.com/qdm12/ddns-updater/internal/provider/providers/example"
"github.com/qdm12/ddns-updater/internal/provider/providers/feno"
"github.com/qdm12/ddns-updater/internal/provider/providers/freedns"
"github.com/qdm12/ddns-updater/internal/provider/providers/gandi"
"github.com/qdm12/ddns-updater/internal/provider/providers/gcp"
Expand Down Expand Up @@ -134,6 +135,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return easydns.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Example:
return example.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Feno:
return feno.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.FreeDNS:
return freedns.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Gandi:
Expand Down
202 changes: 202 additions & 0 deletions internal/provider/providers/feno/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package feno

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"
"strings"

"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/ipextract"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)

type Provider struct {
domain string
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
apiKey string
}

func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
p *Provider, err error,
) {
extraSettings := struct {
APIKey string `json:"api_key"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}

err = validateSettings(domain, owner, extraSettings.APIKey)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}

return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
apiKey: extraSettings.APIKey,
}, nil
}

func validateSettings(domain, owner, apiKey string) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}

switch {
case owner == "*":
return fmt.Errorf("%w", errors.ErrOwnerWildcard)
case apiKey == "":
return fmt.Errorf("%w", errors.ErrAPIKeyNotSet)
case !strings.HasPrefix(apiKey, "feno_live_"):
return fmt.Errorf("%w: it should start with feno_live_", errors.ErrKeyNotValid)
}
return nil
}

func (p *Provider) String() string {
return utils.ToString(p.domain, p.owner, constants.Feno, 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://feno.no/\">FENO</a>",
IPVersion: p.ipVersion.String(),
}
}

// Update updates the IP address for the provider.
// See https://github.com/mrerikcodes/feno-api/blob/main/docs/DDNS.md
func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
u := url.URL{
Scheme: "https",
// The username is ignored by the server, the API key is the password.
User: url.UserPassword("ddns-updater", p.apiKey),
Host: "api.feno.no",
Path: "/v1/nic/update",
}
values := url.Values{}
values.Set("hostname", utils.BuildURLQueryHostname(p.owner, p.domain))
// The server only touches the address family it is sent, so one
// query parameter per family keeps A and AAAA records independent.
if ip.Is6() {
values.Set("myipv6", ip.String())
} else {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()

request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.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{}, fmt.Errorf("doing http request: %w", err)
}
defer response.Body.Close()

s, err := utils.ReadAndCleanBody(response.Body)
if err != nil {
return netip.Addr{}, fmt.Errorf("reading response: %w", err)
}

return parseResponse(response.StatusCode, s, ip)
}

// parseResponse maps the dyndns2 tokens returned by the FENO API to
// errors, and verifies the address echoed back matches the one sent.
func parseResponse(statusCode int, s string, ip netip.Addr) (newIP netip.Addr, err error) {
switch {
case s == "":
return netip.Addr{}, fmt.Errorf("%w", errors.ErrReceivedNoResult)
case strings.HasPrefix(s, constants.Badauth):
return netip.Addr{}, fmt.Errorf("%w", errors.ErrAuth)
case strings.HasPrefix(s, constants.Notfqdn):
return netip.Addr{}, fmt.Errorf("%w: hostname is not a fully qualified domain name",
errors.ErrHostnameNotExists)
case strings.HasPrefix(s, constants.Nohost):
return netip.Addr{}, fmt.Errorf("%w: hostname is not in a zone hosted on FENO nameservers",
errors.ErrHostnameNotExists)
case strings.HasPrefix(s, "numhost"):
return netip.Addr{}, fmt.Errorf("%w: too many hostnames", errors.ErrBadRequest)
case strings.HasPrefix(s, constants.Badagent):
return netip.Addr{}, fmt.Errorf("%w", errors.ErrIPSentMalformed)
case strings.HasPrefix(s, constants.Abuse):
return netip.Addr{}, fmt.Errorf("%w", errors.ErrBannedAbuse)
case strings.HasPrefix(s, "dnserr"):
return netip.Addr{}, fmt.Errorf("%w", errors.ErrDNSServerSide)
case strings.HasPrefix(s, constants.Nineoneone):
return netip.Addr{}, fmt.Errorf("%w: temporary server side error", errors.ErrDNSServerSide)
}

if statusCode != http.StatusOK {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, statusCode, utils.ToSingleLine(s))
}

if !strings.HasPrefix(s, "good") && !strings.HasPrefix(s, "nochg") {
return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnknownResponse, utils.ToSingleLine(s))
}

var ips []netip.Addr
if ip.Is6() {
ips = ipextract.IPv6(s)
} else {
ips = ipextract.IPv4(s)
}

if len(ips) == 0 {
return netip.Addr{}, fmt.Errorf("%w", errors.ErrReceivedNoIP)
}

newIP = ips[0]
if ip.Compare(newIP) != 0 {
return netip.Addr{}, fmt.Errorf("%w: sent ip %s to update but received %s",
errors.ErrIPReceivedMismatch, ip, newIP)
}
return newIP, nil
}
Loading