Refactoring (#63)

- Only calls DNS API(s) once the public IP address changes
- Only one ip method per ip version (ipv4, ipv6, ipv4/v6)
- Gets the ip address once every period for all records
- More object oriented coding instead of functional
- Support to update ipv4 and ipv6 records separately, for supported DNS providers
This commit is contained in:
Quentin McGaw
2020-05-29 20:38:01 -04:00
committed by GitHub
parent af68f9ba0f
commit c23998bd09
59 changed files with 2535 additions and 1756 deletions

View File

@@ -22,8 +22,6 @@ jobs:
- uses: actions/checkout@v2
- name: Buildx setup
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Dockerhub login
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
- name: Run Buildx

View File

@@ -20,10 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: buildx
- name: Buildx setup
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Dockerhub login
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
- name: Run Buildx

View File

@@ -46,4 +46,4 @@ run:
- .github
service:
golangci-lint-version: 1.26.x # use the fixed version to not introduce new linters unexpectedly
golangci-lint-version: 1.27.x # use the fixed version to not introduce new linters unexpectedly

View File

@@ -5,7 +5,7 @@ FROM alpine:${ALPINE_VERSION} AS alpine
RUN apk --update add ca-certificates tzdata
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
ARG GOLANGCI_LINT_VERSION=v1.26.0
ARG GOLANGCI_LINT_VERSION=v1.27.0
RUN apk --update add git
ENV CGO_ENABLED=0
RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s ${GOLANGCI_LINT_VERSION}
@@ -39,16 +39,27 @@ EXPOSE 8000
HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
USER 1000
ENTRYPOINT ["/updater/app"]
ENV DELAY=10m \
ROOT_URL=/ \
ENV \
# Core
PERIOD=5m \
IP_METHOD=cycle \
IPV4_METHOD=cycle \
IPV6_METHOD=cycle \
HTTP_TIMEOUT=10s \
# Web UI
LISTENING_PORT=8000 \
ROOT_URL=/ \
# Backup
BACKUP_PERIOD=0 \
BACKUP_DIRECTORY=/updater/data \
# Other
LOG_ENCODING=console \
LOG_LEVEL=info \
NODE_ID=0 \
HTTP_TIMEOUT=10s \
NODE_ID=-1 \
GOTIFY_URL= \
GOTIFY_TOKEN= \
BACKUP_PERIOD=0 \
BACKUP_DIRECTORY=/updater/data
GOTIFY_TOKEN=
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
COPY --chown=1000 ui/* /updater/ui/

View File

@@ -22,7 +22,7 @@
![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png)
- 12.3MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
- 14MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
- Persistence with a JSON file *updates.json* to store old IP addresses with change times for each record
- Docker healthcheck verifying the DNS resolution of your domains
- Highly configurable
@@ -56,21 +56,17 @@
"provider": "namecheap",
"domain": "example.com",
"host": "@",
"ip_method": "provider",
"delay": 86400,
"password": "e5322165c1d74692bfa6d807100c0310"
},
{
"provider": "duckdns",
"domain": "example.duckdns.org",
"ip_method": "provider",
"token": "00000000-0000-0000-0000-000000000000"
},
{
"provider": "godaddy",
"domain": "example.org",
"host": "subdomain",
"ip_method": "duckduckgo",
"key": "aaaaaaaaaaaaaaaa",
"secret": "aaaaaaaaaaaaaaaa"
}
@@ -104,12 +100,10 @@ Start by having the following content in *config.json*:
{
"provider": "",
"domain": "",
"ip_method": "",
},
{
"provider": "",
"domain": "",
"ip_method": "",
}
]
}
@@ -121,23 +115,11 @@ For all record update configuration, you need the following:
- `"provider"` is the DNS provider and can be `"godaddy"`, `"namecheap"`, `"duckdns"`, `"dreamhost"`, `"cloudflare"`, `"noip"`, `"dnspod"` or `"ddnss"`
- `"domain"`
- `"ip_method"` is the method to obtain your public IP address and can be:
- `"provider"` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap, Infomaniak and NoIP**), most reliable.
- `"opendns"` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip) (reliable)
- `"ifconfig"` using [https://ifconfig.io/ip](https://ifconfig.io/ip) (may be rate limited)
- `"ipinfo"` using [https://ipinfo.io/ip](https://ipinfo.io/ip) (may be rate limited)
- `"ipify"` using [https://api.ipify.org](https://api.ipify.org) (may be rate limited)
- `"ipify6"` using [https://api6.ipify.org](https://api.ipify.org) for IPv6 only (may be rate limited)
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php) for IPv4 only
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php) for IPv6 only
- `"cycle"` to cycle between each external methods, in order to avoid being rate limited
- You can also specify an HTTPS URL to obtain your public IP address (i.e. `"ip_method": "https://ipinfo.io/ip"`)
You can optionnally add the parameters:
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable value.
- `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the periodic Docker healthcheck from running a DNS lookup on your domain.
- `"provider_ip"` can be `true` or `false`. It is only available for the providers `ddnss`, `duckdns`, `infomaniak`, `namecheap` and `noip`. It allows to let your DNS provider to determine your IPv4 address (and/or IPv6 address) automatically when you send an update request, without sending the new IP address detected by the program in the request.
For each DNS provider exist some specific parameters you need to add, as described below:
@@ -188,30 +170,46 @@ Infomaniak:
- `"user"`
- `"password"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DDNSS.de:
- `"user"`
- `"password"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
### Environment variables
| Environment variable | Default | Description |
| --- | --- | --- |
| `DELAY` | `10m` | Default delay between updates, following [this format](https://golang.org/pkg/time/#ParseDuration) |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `PERIOD` | `5m` | Default period of IP address check, following [this format](https://golang.org/pkg/time/#ParseDuration) |
| `IP_METHOD` | `cycle` | Method to obtain the public IP address (ipv4 or ipv6). Can be `cycle`, `opendns`, `ifconfig`, `ipinfo` or an https url |
| `IPV4_METHOD` | `cycle` | Method to obtain the public IPv4 address only. Can be `cycle`, `ipify`, `ddnss4` or an https url |
| `IPV6_METHOD` | `cycle` | Method to obtain the public IPv6 address only. Can be `cycle`, `ipify6`, `ddnss6` or an https url |
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
| `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
The ip methods available are as follows:
- `cycle` cycles between all ip methods available for the specified ip version, if any. This allows you not to be blocked for making too many requests.
- `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
- `ifconfig` using [https://ifconfig.io/ip](https://ifconfig.io/ip)
- `ipinfo` using [https://ipinfo.io/ip](https://ipinfo.io/ip)
- `ipify` using [https://api.ipify.org](https://api.ipify.org)
- `ipify6` using [https://api6.ipify.org](https://api.ipify.org)
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php)
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php)
- You can also specify an HTTPS URL to obtain your public IP address (i.e. `-e IP_METHOD=https://ipinfo.io/ip`)
### Host firewall
@@ -378,13 +376,12 @@ To set it up with DDNS updater:
## TODOs
- [ ] Update dependencies
- [ ] Mockgen instead of mockery
- [ ] Other types or records
- [ ] icon.ico for webpage
- [ ] Record events log
- [ ] Hot reload of config.json
- [ ] Unit tests
- [ ] Move provider specific setup from readme to Wiki
- [ ] ReactJS frontend
- [ ] Live update of website
- [ ] Change settings
- Change settings
- icon.ico for webpage
- Live update periodically (websocket?)
- [ ] Record events log
- [ ] Other types or records: AAAA, C, MX
- [ ] Unit tests
- [ ] Hot reload of config.json

View File

@@ -11,14 +11,6 @@ import (
"os"
"time"
"github.com/qdm12/golibs/admin"
libhealthcheck "github.com/qdm12/golibs/healthcheck"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/network/connectivity"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/server"
"github.com/qdm12/ddns-updater/internal/backup"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/ddns-updater/internal/handlers"
@@ -26,9 +18,15 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/params"
"github.com/qdm12/ddns-updater/internal/persistence"
recordslib "github.com/qdm12/ddns-updater/internal/records"
"github.com/qdm12/ddns-updater/internal/splash"
"github.com/qdm12/ddns-updater/internal/trigger"
"github.com/qdm12/ddns-updater/internal/update"
"github.com/qdm12/golibs/admin"
libhealthcheck "github.com/qdm12/golibs/healthcheck"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/network/connectivity"
"github.com/qdm12/golibs/server"
)
func main() {
@@ -37,6 +35,19 @@ func main() {
// returns 2 on os signal
}
type allParams struct {
period time.Duration
ipMethod models.IPMethod
ipv4Method models.IPMethod
ipv6Method models.IPMethod
dir string
dataDir string
listeningPort string
rootURL string
backupPeriod time.Duration
backupDirectory string
}
func _main(ctx context.Context, timeNow func() time.Time) int {
if libhealthcheck.Mode(os.Args) {
// Running the program in a separate instance through the Docker
@@ -66,20 +77,20 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
return 1
}
dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, err := getParams(paramsReader)
p, err := getParams(paramsReader, logger)
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
persistentDB, err := persistence.NewJSON(dataDir)
persistentDB, err := persistence.NewJSON(p.dataDir)
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
settings, warnings, err := paramsReader.GetSettings(p.dataDir + "/config.json")
for _, w := range warnings {
logger.Warn(w)
notify(2, w)
@@ -92,28 +103,21 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
if len(settings) > 1 {
logger.Info("Found %d settings to update records", len(settings))
} else if len(settings) == 1 {
logger.Info("Found single setting to update records")
logger.Info("Found single setting to update record")
}
for _, err := range connectivity.NewConnectivity(5 * time.Second).Checks("google.com") {
logger.Warn(err)
}
records := make([]models.Record, len(settings))
idToPeriod := make(map[int]time.Duration)
i := 0
for id, setting := range settings {
logger.Info("Reading history from database: domain %s host %s", setting.Domain, setting.Host)
events, err := persistentDB.GetEvents(setting.Domain, setting.Host)
records := make([]recordslib.Record, len(settings))
for i, s := range settings {
logger.Info("Reading history from database: domain %s host %s", s.Domain(), s.Host())
events, err := persistentDB.GetEvents(s.Domain(), s.Host())
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
records[i] = models.NewRecord(setting, events)
idToPeriod[id] = defaultPeriod
if setting.Delay > 0 {
idToPeriod[id] = setting.Delay
}
i++
records[i] = recordslib.New(s, events)
}
HTTPTimeout, err := paramsReader.GetHTTPTimeout()
if err != nil {
@@ -130,30 +134,27 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
}
}()
updater := update.NewUpdater(db, logger, client, notify)
ipGetter := update.NewIPGetter(client, p.ipMethod, p.ipv4Method, p.ipv6Method)
runner := update.NewRunner(updater, ipGetter, logger, timeNow)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
checkError := func(err error) {
if err != nil {
logger.Error(err)
}
}
forceUpdate := trigger.StartUpdates(ctx, updater, idToPeriod, checkError)
forceUpdate := runner.Run(ctx, p.period, records)
forceUpdate()
productionHandlerFunc := handlers.NewHandler(rootURL, dir, db, logger, forceUpdate, checkError).GetHandlerFunc()
productionHandlerFunc := handlers.MakeHandler(p.rootURL, p.dir+"/ui", db, logger, forceUpdate, timeNow)
healthcheckHandlerFunc := libhealthcheck.GetHandler(func() error {
return healthcheck.IsHealthy(db, net.LookupIP, logger)
})
logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL)
logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %q", p.listeningPort, p.rootURL)
notify(1, fmt.Sprintf("Launched with %d records to watch", len(records)))
serverErrors := make(chan []error)
go func() {
serverErrors <- server.RunServers(ctx,
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionHandlerFunc},
server.Settings{Name: "production", Addr: "0.0.0.0:" + p.listeningPort, Handler: productionHandlerFunc},
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
)
}()
go backupRunLoop(ctx, backupPeriod, dir, backupDirectory, logger, timeNow)
go backupRunLoop(ctx, p.backupPeriod, p.dir, p.backupDirectory, logger, timeNow)
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals,
@@ -207,42 +208,52 @@ func setupGotify(paramsReader params.Reader, logger logging.Logger) (notify func
}, nil
}
func getParams(paramsReader params.Reader) (
dir, dataDir,
listeningPort, rootURL string,
defaultPeriod time.Duration,
backupPeriod time.Duration, backupDirectory string,
err error) {
dir, err = paramsReader.GetExeDir()
if err != nil {
return "", "", "", "", 0, 0, "", err
func getParams(paramsReader params.Reader, logger logging.Logger) (p allParams, err error) {
var warnings []string
p.period, warnings, err = paramsReader.GetPeriod()
for _, warning := range warnings {
logger.Warn(warning)
}
dataDir, err = paramsReader.GetDataDir(dir)
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
listeningPort, _, err = paramsReader.GetListeningPort()
p.ipMethod, err = paramsReader.GetIPMethod()
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
rootURL, err = paramsReader.GetRootURL()
p.ipv4Method, err = paramsReader.GetIPv4Method()
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
defaultPeriod, err = paramsReader.GetDelay(libparams.Default("10m"))
p.ipv6Method, err = paramsReader.GetIPv6Method()
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
backupPeriod, err = paramsReader.GetBackupPeriod()
p.dir, err = paramsReader.GetExeDir()
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
backupDirectory, err = paramsReader.GetBackupDirectory()
p.dataDir, err = paramsReader.GetDataDir(p.dir)
if err != nil {
return "", "", "", "", 0, 0, "", err
return p, err
}
return dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, nil
p.listeningPort, _, err = paramsReader.GetListeningPort()
if err != nil {
return p, err
}
p.rootURL, err = paramsReader.GetRootURL()
if err != nil {
return p, err
}
p.backupPeriod, err = paramsReader.GetBackupPeriod()
if err != nil {
return p, err
}
p.backupDirectory, err = paramsReader.GetBackupDirectory()
if err != nil {
return p, err
}
return p, nil
}
func backupRunLoop(ctx context.Context, backupPeriod time.Duration, exeDir, outputDir string,

View File

@@ -4,28 +4,23 @@
"provider": "namecheap",
"domain": "example.com",
"host": "@",
"ip_method": "provider",
"delay": 86400,
"password": "e5322165c1d74692bfa6d807100c0310"
},
{
"provider": "duckdns",
"domain": "example.duckdns.org",
"ip_method": "provider",
"token": "00000000-0000-0000-0000-000000000000"
},
{
"provider": "godaddy",
"domain": "example.org",
"host": "subdomain",
"ip_method": "google",
"key": "aaaaaaaaaaaaaaaa",
"secret": "aaaaaaaaaaaaaaaa"
},
{
"provider": "dreamhost",
"domain": "example.info",
"ip_method": "opendns",
"key": "aaaaaaaaaaaaaaaa"
}
]

View File

@@ -9,15 +9,24 @@ services:
volumes:
- ./data:/updater/data
environment:
- DELAY=300s
- ROOT_URL=/
- PERIOD=5m
- IP_METHOD=cycle
- IPV4_METHOD=cycle
- IPV6_METHOD=cycle
- HTTP_TIMEOUT=10s
# Web UI
- LISTENING_PORT=8000
- ROOT_URL=/
# Backup
- BACKUP_PERIOD=0 # 0 to disable
- BACKUP_DIRECTORY=/updater/data
# Other
- LOG_ENCODING=console
- LOG_LEVEL=info
- NODE_ID=0
- HTTP_TIMEOUT=10s
- NODE_ID=-1 # -1 to disable
- GOTIFY_URL=
- GOTIFY_TOKEN=
- BACKUP_PERIOD=0
- BACKUP_DIRECTORY=/updater/data
restart: always

2
go.mod
View File

@@ -6,6 +6,6 @@ require (
github.com/golang/mock v1.4.3
github.com/google/uuid v1.1.1
github.com/kyokomi/emoji v2.2.2+incompatible
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e
github.com/stretchr/testify v1.5.1
)

2
go.sum
View File

@@ -78,6 +78,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151 h1:5q8oyhJqgQyW5v427CDC34SobllqiJCLLfS3Z4EeLCI=
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e h1:UNeyDMj3sAlD/tQw03q5OSNw94BH7JGctvlaQ4Mp52U=
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -1,36 +0,0 @@
package constants
import "github.com/qdm12/ddns-updater/internal/models"
const (
HTMLFail models.HTML = `<font color="red"><b>Failure</b></font>`
HTMLSuccess models.HTML = `<font color="green"><b>Success</b></font>`
HTMLUpdate models.HTML = `<font color="#00CC66"><b>Up to date</b></font>`
HTMLUpdating models.HTML = `<font color="orange"><b>Updating</b></font>`
)
const (
// TODO have a struct model containing URL, name for each provider
HTMLNamecheap models.HTML = "<a href=\"https://namecheap.com\">Namecheap</a>"
HTMLGodaddy models.HTML = "<a href=\"https://godaddy.com\">GoDaddy</a>"
HTMLDuckDNS models.HTML = "<a href=\"https://duckdns.org\">DuckDNS</a>"
HTMLDreamhost models.HTML = "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>"
HTMLCloudflare models.HTML = "<a href=\"https://www.cloudflare.com\">Cloudflare</a>"
HTMLNoIP models.HTML = "<a href=\"https://www.noip.com/\">NoIP</a>"
HTMLDNSPod models.HTML = "<a href=\"https://www.dnspod.cn/\">DNSPod</a>"
HTMLInfomaniak models.HTML = "<a href=\"https://www.infomaniak.com/\">Infomaniak</a>"
HTMLDdnssde models.HTML = "<a href=\"https://ddnss.de/\">DDNSS.de</a>"
)
const (
HTMLGoogle models.HTML = "<a href=\"https://google.com/search?q=ip\">Google</a>"
HTMLOpenDNS models.HTML = "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
HTMLIfconfig models.HTML = "<a href=\"https://ifconfig.io\">ifconfig.io</a>"
HTMLIpinfo models.HTML = "<a href=\"https://ipinfo.io\">ipinfo.io</a>"
HTMLIpify models.HTML = "<a href=\"https://api.ipify.org\">api.ipify.org</a>"
HTMLIpify6 models.HTML = "<a href=\"https://api6.ipify.org\">api6.ipify.org</a>"
HTMLDdnss models.HTML = "<a href=\"https://ddnss.de/meineip.php\">ddnss.de</a>"
HTMLDdnss4 models.HTML = "<a href=\"https://ip4.ddnss.de/meineip.php\">ip4.ddnss.de</a>"
HTMLDdnss6 models.HTML = "<a href=\"https://ip6.ddnss.de/meineip.php\">ip6.ddns.de</a>"
HTMLCycle models.HTML = "Cycling"
)

View File

@@ -3,6 +3,7 @@ package constants
import "github.com/qdm12/ddns-updater/internal/models"
const (
IPv4 models.IPVersion = "ipv4"
IPv6 models.IPVersion = "ipv6"
IPv4 models.IPVersion = "ipv4"
IPv6 models.IPVersion = "ipv6"
IPv4OrIPv6 models.IPVersion = "ipv4 or ipv6"
)

View File

@@ -4,50 +4,48 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
)
const (
PROVIDER models.IPMethod = "provider"
OPENDNS models.IPMethod = "opendns"
IFCONFIG models.IPMethod = "ifconfig"
IPINFO models.IPMethod = "ipinfo"
IPIFY models.IPMethod = "ipify"
IPIFY6 models.IPMethod = "ipify6"
CYCLE models.IPMethod = "cycle"
DDNSS models.IPMethod = "ddnss"
DDNSS4 models.IPMethod = "ddnss4"
DDNSS6 models.IPMethod = "ddnss6"
// Retro compatibility only
GOOGLE models.IPMethod = "google"
)
func IPMethodMapping() map[models.IPMethod]string {
return map[models.IPMethod]string{
PROVIDER: string(PROVIDER),
CYCLE: string(CYCLE),
OPENDNS: "https://diagnostic.opendns.com/myip",
IFCONFIG: "https://ifconfig.io/ip",
IPINFO: "https://ipinfo.io/ip",
IPIFY: "https://api.ipify.org",
IPIFY6: "https://api6.ipify.org",
DDNSS: "https://ip4.ddnss.de/meineip.php",
DDNSS4: "https://ip4.ddnss.de/meineip.php",
DDNSS6: "https://ip6.ddnss.de/meineip.php",
func IPMethods() []models.IPMethod {
return []models.IPMethod{
{
Name: "cycle",
},
{
Name: "opendns",
URL: "https://diagnostic.opendns.com/myip",
IPv4: true,
IPv6: true,
},
{
Name: "ifconfig",
URL: "https://ifconfig.io/ip",
IPv4: true,
IPv6: true,
},
{
Name: "ipinfo",
URL: "https://ipinfo.io/ip",
IPv4: true,
IPv6: true,
},
{
Name: "ipify",
URL: "https://api.ipify.org",
IPv4: true,
},
{
Name: "ipify6",
URL: "https://api6.ipify.org",
IPv6: true,
},
{
Name: "ddnss4",
URL: "https://ip4.ddnss.de/meineip.php",
IPv4: true,
},
{
Name: "ddnss6",
URL: "https://ip6.ddnss.de/meineip.php",
IPv6: true,
},
}
}
func IPMethodChoices() (choices []models.IPMethod) {
for choice := range IPMethodMapping() {
choices = append(choices, choice)
}
return choices
}
func IPMethodExternalChoices() (choices []models.IPMethod) {
for _, choice := range IPMethodChoices() {
switch choice {
case PROVIDER, CYCLE:
default:
choices = append(choices, choice)
}
}
return choices
}

View File

@@ -1,20 +0,0 @@
package constants
import (
"testing"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_IPMethodChoices(t *testing.T) {
t.Parallel()
choices := IPMethodChoices()
assert.ElementsMatch(t, []models.IPMethod{"ipinfo", "ipify", "ipify6", "provider", "cycle", "opendns", "ifconfig", "ddnss", "ddnss4", "ddnss6"}, choices)
}
func Test_IPMethodExternalChoices(t *testing.T) {
t.Parallel()
choices := IPMethodExternalChoices()
assert.ElementsMatch(t, []models.IPMethod{"ipinfo", "ipify", "ipify6", "ifconfig", "opendns", "ddnss", "ddnss4", "ddnss6"}, choices)
}

View File

@@ -5,26 +5,27 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/persistence"
"github.com/qdm12/ddns-updater/internal/records"
)
type Database interface {
Close() error
Insert(record models.Record) (id int)
Select(id int) (record models.Record, err error)
SelectAll() (records []models.Record)
Update(id int, record models.Record) error
Insert(record records.Record) (id int)
Select(id int) (record records.Record, err error)
SelectAll() (records []records.Record)
Update(id int, record records.Record) error
// From persistence database
GetEvents(domain, host string) (events []models.HistoryEvent, err error)
}
type database struct {
data []models.Record
data []records.Record
sync.RWMutex
persistentDB persistence.Database
}
// NewDatabase creates a new in memory database
func NewDatabase(data []models.Record, persistentDB persistence.Database) Database {
func NewDatabase(data []records.Record, persistentDB persistence.Database) Database {
return &database{
data: data,
persistentDB: persistentDB,

View File

@@ -3,17 +3,17 @@ package data
import (
"fmt"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/records"
)
func (db *database) Insert(record models.Record) (id int) {
func (db *database) Insert(record records.Record) (id int) {
db.Lock()
defer db.Unlock()
db.data = append(db.data, record)
return len(db.data) - 1
}
func (db *database) Select(id int) (record models.Record, err error) {
func (db *database) Select(id int) (record records.Record, err error) {
db.RLock()
defer db.RUnlock()
if id < 0 {
@@ -25,7 +25,7 @@ func (db *database) Select(id int) (record models.Record, err error) {
return db.data[id], nil
}
func (db *database) SelectAll() (records []models.Record) {
func (db *database) SelectAll() (records []records.Record) {
db.RLock()
defer db.RUnlock()
return db.data

View File

@@ -4,13 +4,14 @@ import (
"fmt"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/records"
)
func (db *database) GetEvents(domain, host string) (events []models.HistoryEvent, err error) {
return db.persistentDB.GetEvents(domain, host)
}
func (db *database) Update(id int, record models.Record) error {
func (db *database) Update(id int, record records.Record) error {
db.Lock()
defer db.Unlock()
if id < 0 {
@@ -25,8 +26,8 @@ func (db *database) Update(id int, record models.Record) error {
// new IP address added
if newCount > currentCount {
if err := db.persistentDB.StoreNewIP(
record.Settings.Domain,
record.Settings.Host,
record.Settings.Domain(),
record.Settings.Host(),
record.History.GetCurrentIP(),
record.History.GetSuccessTime(),
); err != nil {

View File

@@ -7,61 +7,31 @@ import (
"time"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/ddns-updater/internal/html"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
)
// Handler contains a handler function
type Handler interface {
GetHandlerFunc() http.HandlerFunc
}
type handler struct {
rootURL string
uiDir string
db data.Database
logger logging.Logger
forceUpdate func()
onError func(err error)
getTime func() time.Time
}
// NewHandler returns a Handler object
func NewHandler(rootURL, uiDir string, db data.Database, logger logging.Logger,
forceUpdate func(), onError func(err error)) Handler {
return &handler{
rootURL: rootURL,
uiDir: uiDir,
db: db,
logger: logger,
forceUpdate: forceUpdate,
onError: onError,
getTime: time.Now,
}
}
// GetHandlerFunc returns a router with all the necessary routes configured
func (h *handler) GetHandlerFunc() http.HandlerFunc {
// MakeHandler returns a router with all the necessary routes configured
func MakeHandler(rootURL, uiDir string, db data.Database, logger logging.Logger, forceUpdate func(), timeNow func() time.Time) http.HandlerFunc {
logger = logger.WithPrefix("http server: ")
return func(w http.ResponseWriter, r *http.Request) {
h.logger.Info("received HTTP request at %s", r.RequestURI)
logger.Info("HTTP %s %s", r.Method, r.RequestURI)
switch {
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/":
// TODO: Forms to change existing updates or add some
t := template.Must(template.ParseFiles(h.uiDir + "/ui/index.html"))
case r.Method == http.MethodGet && r.RequestURI == rootURL+"/":
t := template.Must(template.ParseFiles(uiDir + "/index.html"))
var htmlData models.HTMLData
for _, record := range h.db.SelectAll() {
row := html.ConvertRecord(record, h.getTime())
for _, record := range db.SelectAll() {
row := record.HTML(timeNow())
htmlData.Rows = append(htmlData.Rows, row)
}
if err := t.ExecuteTemplate(w, "index.html", htmlData); err != nil {
h.logger.Warn(err)
logger.Warn(err)
fmt.Fprint(w, "An error occurred creating this webpage")
}
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/update":
h.logger.Info("Update started manually")
h.forceUpdate()
http.Redirect(w, r, h.rootURL, 301)
case r.Method == http.MethodGet && r.RequestURI == rootURL+"/update":
logger.Info("Update started manually")
forceUpdate()
http.Redirect(w, r, rootURL, 301)
}
}
}

View File

@@ -22,7 +22,7 @@ func IsHealthy(db data.Database, lookupIP lookupIPFunc, logger logging.Logger) (
for _, record := range records {
if record.Status == constants.FAIL {
return fmt.Errorf("%s", record.String())
} else if record.Settings.NoDNSLookup {
} else if !record.Settings.DNSLookup() {
continue
}
lookedUpIPs, err := lookupIP(record.Settings.BuildDomainName())

View File

@@ -1,134 +0,0 @@
package html
import (
"fmt"
"strings"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
func ConvertRecord(record models.Record, now time.Time) models.HTMLRow {
const NotAvailable = "N/A"
row := models.HTMLRow{
Domain: convertDomain(record.Settings.BuildDomainName()),
Host: models.HTML(record.Settings.Host),
Provider: convertProvider(record.Settings.Provider),
IPMethod: convertIPMethod(record.Settings.IPMethod, record.Settings.Provider),
}
message := record.Message
if record.Status == constants.UPTODATE {
message = "no IP change for " + record.History.GetDurationSinceSuccess(now)
}
if len(message) > 0 {
message = fmt.Sprintf("(%s)", message)
}
if len(record.Status) == 0 {
row.Status = NotAvailable
} else {
row.Status = models.HTML(fmt.Sprintf("%s %s, %s",
convertStatus(record.Status),
message,
time.Since(record.Time).Round(time.Second).String()+" ago"))
}
currentIP := record.History.GetCurrentIP()
if currentIP != nil {
row.CurrentIP = models.HTML(`<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>")
} else {
row.CurrentIP = NotAvailable
}
previousIPs := record.History.GetPreviousIPs()
row.PreviousIPs = NotAvailable
if len(previousIPs) > 0 {
var previousIPsStr []string
const maxPreviousIPs = 2
for i, previousIP := range previousIPs {
if i == maxPreviousIPs {
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
break
}
previousIPsStr = append(previousIPsStr, previousIP.String())
}
row.PreviousIPs = models.HTML(strings.Join(previousIPsStr, ", "))
}
return row
}
func convertStatus(status models.Status) models.HTML {
switch status {
case constants.SUCCESS:
return constants.HTMLSuccess
case constants.FAIL:
return constants.HTMLFail
case constants.UPTODATE:
return constants.HTMLUpdate
case constants.UPDATING:
return constants.HTMLUpdating
default:
return "Unknown status"
}
}
func convertProvider(provider models.Provider) models.HTML {
switch provider {
case constants.NAMECHEAP:
return constants.HTMLNamecheap
case constants.GODADDY:
return constants.HTMLGodaddy
case constants.DUCKDNS:
return constants.HTMLDuckDNS
case constants.DREAMHOST:
return constants.HTMLDreamhost
case constants.CLOUDFLARE:
return constants.HTMLCloudflare
case constants.NOIP:
return constants.HTMLNoIP
case constants.DNSPOD:
return constants.HTMLDNSPod
case constants.INFOMANIAK:
return constants.HTMLInfomaniak
case constants.DDNSSDE:
return constants.HTMLDdnssde
default:
s := string(provider)
if strings.HasPrefix("https://", s) {
shorterName := strings.TrimPrefix(s, "https://")
shorterName = strings.TrimSuffix(shorterName, "/")
return models.HTML(fmt.Sprintf("<a href=\"%s\">%s</a>", s, shorterName))
}
return models.HTML(string(provider))
}
}
func convertIPMethod(ipMethod models.IPMethod, provider models.Provider) models.HTML {
// TODO map to icons
switch ipMethod {
case constants.PROVIDER:
return convertProvider(provider)
case constants.OPENDNS:
return constants.HTMLOpenDNS
case constants.IFCONFIG:
return constants.HTMLIfconfig
case constants.IPINFO:
return constants.HTMLIpinfo
case constants.IPIFY:
return constants.HTMLIpify
case constants.IPIFY6:
return constants.HTMLIpify6
case constants.DDNSS:
return constants.HTMLDdnss
case constants.DDNSS4:
return constants.HTMLDdnss4
case constants.DDNSS6:
return constants.HTMLDdnss6
case constants.CYCLE:
return constants.HTMLCycle
default:
return models.HTML(string(ipMethod))
}
}
func convertDomain(domain string) models.HTML {
return models.HTML("<a href=\"http://" + domain + "\">" + domain + "</a>")
}

View File

@@ -3,8 +3,6 @@ package models
type (
// Provider is a possible DNS provider
Provider string
// IPMethod is a method to obtain your public IP address
IPMethod string
// Status is the record config status
Status string
// HTML is for constants HTML strings

View File

@@ -12,7 +12,7 @@ type HTMLRow struct {
Domain HTML
Host HTML
Provider HTML
IPMethod HTML
IPVersion HTML
Status HTML
CurrentIP HTML
PreviousIPs HTML

View File

@@ -0,0 +1,9 @@
package models
// IPMethod is a method to obtain your public IP address
type IPMethod struct {
Name string
URL string
IPv4 bool
IPv6 bool
}

View File

@@ -1,57 +0,0 @@
package models
import (
"encoding/json"
"time"
)
// Settings contains the elements to update the DNS record
// nolint: maligned
type Settings struct {
Domain string
Host string
Provider Provider
IPMethod IPMethod
IPVersion IPVersion
Delay time.Duration
NoDNSLookup bool
// Provider dependent fields
Password string // Namecheap, Infomaniak, DDNSS and NoIP only
Key string // GoDaddy, Dreamhost and Cloudflare only
Secret string // GoDaddy only
Token string // Cloudflare and DuckDNS only
Email string // Cloudflare only
UserServiceKey string // Cloudflare only
ZoneIdentifier string // Cloudflare only
Identifier string // Cloudflare only
Proxied bool // Cloudflare only
TTL uint // Cloudflare only
Username string // NoIP, Infomaniak, DDNSS only
}
func (settings *Settings) String() string {
b, _ := json.Marshal(
struct {
Domain string `json:"domain"`
Host string `json:"host"`
Provider string `json:"provider"`
}{
settings.Domain,
settings.Host,
string(settings.Provider),
},
)
return string(b)
}
// BuildDomainName builds the domain name from the domain and the host of the settings
func (settings *Settings) BuildDomainName() string {
switch settings.Host {
case "@":
return settings.Domain
case "*":
return "any." + settings.Domain
default:
return settings.Host + "." + settings.Domain
}
}

View File

@@ -1,9 +1,11 @@
package network
import (
"bytes"
"fmt"
"net"
"net/http"
"sort"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
@@ -16,24 +18,92 @@ import (
func GetPublicIP(client network.Client, url string, ipVersion models.IPVersion) (ip net.IP, err error) {
content, status, err := client.GetContent(url)
if err != nil {
return nil, fmt.Errorf("cannot get public %s address from %s: %s", ipVersion, url, err)
return nil, fmt.Errorf("cannot get public %s address: %w", ipVersion, err)
} else if status != http.StatusOK {
return nil, fmt.Errorf("cannot get public %s address from %s: HTTP status code %d", ipVersion, url, status)
}
verifier := verification.NewVerifier()
regexSearch := verifier.SearchIPv4
if ipVersion == constants.IPv6 {
regexSearch = verifier.SearchIPv6
s := string(content)
switch ipVersion {
case constants.IPv4:
return searchIP(constants.IPv4, s)
case constants.IPv6:
return searchIP(constants.IPv6, s)
case constants.IPv4OrIPv6:
var ipv4Err, ipv6Err error
ip, ipv4Err = searchIP(constants.IPv4, s)
if ipv4Err != nil {
ip, ipv6Err = searchIP(constants.IPv6, s)
}
if ipv6Err != nil {
return nil, fmt.Errorf("%s, %s", ipv4Err, ipv6Err)
}
return ip, nil
default:
return nil, fmt.Errorf("ip version %q not supported", ipVersion)
}
ips := regexSearch(string(content))
if ips == nil {
return nil, fmt.Errorf("no public %s address found at %s", ipVersion, url)
} else if len(ips) > 1 {
return nil, fmt.Errorf("multiple public %s addresses found at %s: %s", ipVersion, url, strings.Join(ips, " "))
}
ip = net.ParseIP(ips[0])
if ip == nil { // in case the regex is not restrictive enough
return nil, fmt.Errorf("Public IP address %q found at %s is not valid", ips[0], url)
}
return ip, nil
}
func searchIP(version models.IPVersion, s string) (ip net.IP, err error) {
verifier := verification.NewVerifier()
var regexSearch func(s string) []string
switch version {
case constants.IPv4:
regexSearch = verifier.SearchIPv4
case constants.IPv6:
regexSearch = verifier.SearchIPv6
default:
return nil, fmt.Errorf("ip version %q is not supported for regex search", version)
}
ips := regexSearch(s)
if ips == nil {
return nil, fmt.Errorf("no public %s address found", version)
}
uniqueIPs := make(map[string]struct{})
for _, ipString := range ips {
uniqueIPs[ipString] = struct{}{}
}
netIPs := []net.IP{}
for ipString := range uniqueIPs {
netIP := net.ParseIP(ipString)
if netIP == nil || netIPIsPrivate(netIP) {
// in case the regex is not restrictive enough
// or the IP address is private
continue
}
netIPs = append(netIPs, netIP)
}
switch len(netIPs) {
case 0:
return nil, fmt.Errorf("no public %s address found", version)
case 1:
return netIPs[0], nil
default:
sort.Slice(netIPs, func(i, j int) bool {
return bytes.Compare(netIPs[i], netIPs[j]) < 0
})
ips = make([]string, len(netIPs))
for i := range netIPs {
ips[i] = netIPs[i].String()
}
return nil, fmt.Errorf("multiple public %s addresses found: %s", version, strings.Join(ips, " "))
}
}
func netIPIsPrivate(netIP net.IP) bool {
for _, privateCIDRBlock := range [8]string{
"127.0.0.1/8", // localhost
"10.0.0.0/8", // 24-bit block
"172.16.0.0/12", // 20-bit block
"192.168.0.0/16", // 16-bit block
"169.254.0.0/16", // link local address
"::1/128", // localhost IPv6
"fc00::/7", // unique local address IPv6
"fe80::/10", // link local address IPv6
} {
_, CIDR, _ := net.ParseCIDR(privateCIDRBlock)
if CIDR.Contains(netIP) {
return true
}
}
return false
}

View File

@@ -27,36 +27,47 @@ func Test_GetPublicIP(t *testing.T) {
"network error": {
IPVersion: constants.IPv4,
mockErr: fmt.Errorf("error"),
err: fmt.Errorf("cannot get public ipv4 address from https://getmyip.com: error"),
err: fmt.Errorf("cannot get public ipv4 address: error"),
},
"bad status": {
IPVersion: constants.IPv4,
mockStatus: http.StatusUnauthorized,
err: fmt.Errorf("cannot get public ipv4 address from https://getmyip.com: HTTP status code 401"),
},
"no IPs in content": {
"ipv4 address": {
IPVersion: constants.IPv4,
mockContent: []byte(""),
mockContent: []byte("55.55.55.55"),
mockStatus: http.StatusOK,
err: fmt.Errorf("no public ipv4 address found at https://getmyip.com"),
ip: net.IP{55, 55, 55, 55},
},
"multiple IPs in content": {
IPVersion: constants.IPv4,
mockContent: []byte("10.10.10.10 50.50.50.50"),
mockStatus: http.StatusOK,
err: fmt.Errorf("multiple public ipv4 addresses found at https://getmyip.com: 10.10.10.10 50.50.50.50"),
},
"single IP in content": {
IPVersion: constants.IPv4,
mockContent: []byte("10.10.10.10"),
mockStatus: http.StatusOK,
ip: net.IP{10, 10, 10, 10},
},
"single IPv6 in content": {
"ipv6 address": {
IPVersion: constants.IPv6,
mockContent: []byte("::fe"),
mockContent: []byte("ad07:e846:51ac:6cd0:0000:0000:0000:0000"),
mockStatus: http.StatusOK,
ip: net.IP{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xfe},
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
"ipv4 or ipv6 found ipv4": {
IPVersion: constants.IPv4OrIPv6,
mockContent: []byte("55.55.55.55"),
mockStatus: http.StatusOK,
ip: net.IP{55, 55, 55, 55},
},
"ipv4 or ipv6 found ipv6": {
IPVersion: constants.IPv4OrIPv6,
mockContent: []byte("ad07:e846:51ac:6cd0:0000:0000:0000:0000"),
mockStatus: http.StatusOK,
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
"ipv4 or ipv6 not found": {
IPVersion: constants.IPv4OrIPv6,
mockContent: []byte("abc"),
mockStatus: http.StatusOK,
err: fmt.Errorf("no public ipv4 address found, no public ipv6 address found"),
},
"unsupported ip version": {
IPVersion: models.IPVersion("x"),
mockStatus: http.StatusOK,
err: fmt.Errorf("ip version \"x\" not supported"),
},
}
const URL = "https://getmyip.com"
@@ -79,3 +90,66 @@ func Test_GetPublicIP(t *testing.T) {
})
}
}
func Test_searchIP(t *testing.T) {
t.Parallel()
tests := map[string]struct {
IPVersion models.IPVersion
s string
ip net.IP
err error
}{
"unsupported ip version": {
IPVersion: constants.IPv4OrIPv6,
err: fmt.Errorf("ip version \"ipv4 or ipv6\" is not supported for regex search"),
},
"no content": {
IPVersion: constants.IPv4,
err: fmt.Errorf("no public ipv4 address found"),
},
"single ipv4 address": {
IPVersion: constants.IPv4,
s: "abcd 55.55.55.55 abcd",
ip: net.IP{55, 55, 55, 55},
},
"single ipv6 address": {
IPVersion: constants.IPv6,
s: "abcd bd07:e846:51ac:6cd0:0000:0000:0000:0000 abcd",
ip: net.IP{0xbd, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
"single private ipv4 address": {
IPVersion: constants.IPv4,
s: "abcd 10.0.0.3 abcd",
err: fmt.Errorf("no public ipv4 address found"),
},
"single private ipv6 address": {
IPVersion: constants.IPv6,
s: "abcd ::1 abcd",
err: fmt.Errorf("no public ipv6 address found"),
},
"2 ipv4 addresses": {
IPVersion: constants.IPv4,
s: "55.55.55.55 56.56.56.56",
err: fmt.Errorf("multiple public ipv4 addresses found: 55.55.55.55 56.56.56.56"),
},
"2 ipv6 addresses": {
IPVersion: constants.IPv6,
s: "bd07:e846:51ac:6cd0:0000:0000:0000:0000 ad07:e846:51ac:6cd0:0000:0000:0000:0000",
err: fmt.Errorf("multiple public ipv6 addresses found: ad07:e846:51ac:6cd0:: bd07:e846:51ac:6cd0::"), //nolint:go-lint
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
ip, err := searchIP(tc.IPVersion, tc.s)
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
assert.True(t, tc.ip.Equal(ip))
})
}
}

View File

@@ -2,232 +2,21 @@ package params
import (
"fmt"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
func settingsGeneralChecks(settings models.Settings, matchDomain func(s string) bool) error {
switch {
case !ipMethodIsValid(settings.IPMethod):
return fmt.Errorf("IP method %q is not recognized", settings.IPMethod)
case settings.IPVersion != constants.IPv4 && settings.IPVersion != constants.IPv6:
return fmt.Errorf("IP version %q is not recognized", settings.IPVersion)
case !matchDomain(settings.Domain):
return fmt.Errorf("invalid domain name format")
case len(settings.Host) == 0:
return fmt.Errorf("host cannot be empty")
default:
return nil
}
}
func settingsIPVersionChecks(ipVersion models.IPVersion, ipMethod models.IPMethod, provider models.Provider) error {
func settingsIPVersionChecks(ipVersion models.IPVersion, provider models.Provider) error {
switch ipVersion {
case constants.IPv4:
switch ipMethod {
case constants.IPIFY6, constants.DDNSS6:
return fmt.Errorf("IP method %s is only for IPv6 addresses", ipMethod)
}
case constants.IPv4OrIPv6, constants.IPv4:
case constants.IPv6:
switch ipMethod {
case constants.IPIFY, constants.DDNSS4:
return fmt.Errorf("IP method %s is only for IPv4 addresses", ipMethod)
}
switch provider {
case constants.GODADDY, constants.CLOUDFLARE, constants.DNSPOD, constants.DREAMHOST, constants.DUCKDNS, constants.NOIP:
return fmt.Errorf("IPv6 support for %s is not supported yet", provider)
}
}
return nil
}
func settingsIPMethodChecks(ipMethod models.IPMethod, provider models.Provider) error {
if ipMethod == constants.PROVIDER {
switch provider {
case constants.GODADDY, constants.DREAMHOST, constants.CLOUDFLARE, constants.DNSPOD, constants.DDNSSDE:
return fmt.Errorf("unsupported IP update method %q", ipMethod)
}
}
return nil
}
func settingsNamecheapChecks(password string) error {
if !constants.MatchNamecheapPassword(password) {
return fmt.Errorf("invalid password format")
}
return nil
}
func settingsGoDaddyChecks(key, secret string) error {
switch {
case !constants.MatchGodaddyKey(key):
return fmt.Errorf("invalid key format")
case !constants.MatchGodaddySecret(secret):
return fmt.Errorf("invalid secret format")
}
return nil
}
func settingsDuckDNSChecks(token, host string) error {
switch {
case !constants.MatchDuckDNSToken(token):
return fmt.Errorf("invalid token format")
case host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func settingsDreamhostChecks(key, host string) error {
switch {
case !constants.MatchDreamhostKey(key):
return fmt.Errorf("invalid key format")
case host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func settingsCloudflareChecks(key, email, userServiceKey, token, zoneIdentifier, identifier string, ttl uint, matchEmail func(s string) bool) error {
switch {
case len(key) > 0: // email and key must be provided
switch {
case !constants.MatchCloudflareKey(key):
return fmt.Errorf("invalid key format")
case !matchEmail(email):
return fmt.Errorf("invalid email format")
}
case len(userServiceKey) > 0: // only user service key
if !constants.MatchCloudflareKey(key) {
return fmt.Errorf("invalid user service key format")
}
default: // API token only
if !constants.MatchCloudflareToken(token) {
return fmt.Errorf("invalid API token key format")
}
}
switch {
case len(zoneIdentifier) == 0:
return fmt.Errorf("zone identifier cannot be empty")
case len(identifier) == 0:
return fmt.Errorf("identifier cannot be empty")
case ttl == 0:
return fmt.Errorf("TTL cannot be left to 0")
}
return nil
}
func settingsNoIPChecks(username, password, host string) error {
switch {
case len(username) == 0:
return fmt.Errorf("username cannot be empty")
case len(username) > 50:
return fmt.Errorf("username cannot be longer than 50 characters")
case len(password) == 0:
return fmt.Errorf("password cannot be empty")
case host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func settingsDNSPodChecks(token string) error {
if len(token) == 0 {
return fmt.Errorf("token cannot be empty")
}
return nil
}
func settingsInfomaniakChecks(username, password, host string) error {
switch {
case len(username) == 0:
return fmt.Errorf("username cannot be empty")
case len(password) == 0:
return fmt.Errorf("password cannot be empty")
case host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func settingsDdnssdeChecks(username, password, host string) error {
switch {
case len(username) == 0:
return fmt.Errorf("username cannot be empty")
case len(password) == 0:
return fmt.Errorf("password cannot be empty")
case host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func (r *reader) isConsistent(settings models.Settings) error {
if err := settingsGeneralChecks(settings, r.verifier.MatchDomain); err != nil {
return err
}
if err := settingsIPVersionChecks(settings.IPVersion, settings.IPMethod, settings.Provider); err != nil {
return err
}
if err := settingsIPMethodChecks(settings.IPMethod, settings.Provider); err != nil {
return err
}
// Checks for each DNS provider
switch settings.Provider {
case constants.NAMECHEAP:
if err := settingsNamecheapChecks(settings.Password); err != nil {
return err
}
case constants.GODADDY:
if err := settingsGoDaddyChecks(settings.Key, settings.Secret); err != nil {
return err
}
case constants.DUCKDNS:
if err := settingsDuckDNSChecks(settings.Token, settings.Host); err != nil {
return err
}
case constants.DREAMHOST:
if err := settingsDreamhostChecks(settings.Key, settings.Host); err != nil {
return err
}
case constants.CLOUDFLARE:
if err := settingsCloudflareChecks(settings.Key, settings.Email, settings.UserServiceKey, settings.Token, settings.ZoneIdentifier, settings.Identifier, settings.TTL, r.verifier.MatchEmail); err != nil {
return err
}
case constants.NOIP:
if err := settingsNoIPChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
case constants.DNSPOD:
if err := settingsDNSPodChecks(settings.Password); err != nil {
return err
}
case constants.INFOMANIAK:
if err := settingsInfomaniakChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
case constants.DDNSSDE:
if err := settingsDdnssdeChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
default:
return fmt.Errorf("provider %q is not supported", settings.Provider)
return fmt.Errorf("ip version %q is not valid", ipVersion)
}
return nil
}
func ipMethodIsValid(ipMethod models.IPMethod) bool {
for _, possibility := range constants.IPMethodChoices() {
if ipMethod == possibility {
return true
}
}
url, err := url.Parse(string(ipMethod))
if err != nil || url == nil || url.Scheme != "https" {
return false
}
return true
}

View File

@@ -1,45 +0,0 @@
package params
import (
"testing"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_ipMethodIsValid(t *testing.T) {
t.Parallel()
tests := map[string]struct {
ipMethod models.IPMethod
valid bool
}{
"empty method": {
ipMethod: "",
valid: false,
},
"non existing method": {
ipMethod: "abc",
valid: false,
},
"existing method": {
ipMethod: "opendns",
valid: true,
},
"http url": {
ipMethod: "http://ipinfo.io/ip",
valid: false,
},
"https url": {
ipMethod: "https://ipinfo.io/ip",
valid: true,
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
valid := ipMethodIsValid(tc.ipMethod)
assert.Equal(t, tc.valid, valid)
})
}
}

View File

@@ -3,89 +3,104 @@ package params
import (
"encoding/json"
"fmt"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/settings"
"github.com/qdm12/golibs/verification"
)
// nolint: maligned
type settingsType struct {
Provider string `json:"provider"`
Domain string `json:"domain"`
IPMethod string `json:"ip_method"`
IPVersion string `json:"ip_version"`
Delay uint64 `json:"delay"`
NoDNSLookup bool `json:"no_dns_lookup"`
Host string `json:"host"`
Password string `json:"password"` // Namecheap, NoIP only
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
Secret string `json:"secret"` // GoDaddy only
Token string `json:"token"` // DuckDNS and Cloudflare only
Email string `json:"email"` // Cloudflare only
Username string `json:"username"` // NoIP only
UserServiceKey string `json:"user_service_key"` // Cloudflare only
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
Identifier string `json:"identifier"` // Cloudflare only
Proxied bool `json:"proxied"` // Cloudflare only
TTL uint `json:"ttl"` // Cloudflare only
type commonSettings struct {
Provider string `json:"provider"`
Domain string `json:"domain"`
Host string `json:"host"`
IPVersion string `json:"ip_version"`
NoDNSLookup bool `json:"no_dns_lookup"`
// Retro values for warnings
IPMethod *string `json:"ip_method,omitempty"`
Delay *uint64 `json:"delay,omitempty"`
}
// GetSettings obtain the update settings from config.json
func (r *reader) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
func (r *reader) GetSettings(filePath string) (allSettings []settings.Settings, warnings []string, err error) {
bytes, err := r.readFile(filePath)
if err != nil {
return nil, nil, err
}
var config struct {
Settings []settingsType `json:"settings"`
}
config := struct {
CommonSettings []commonSettings `json:"settings"`
}{}
rawConfig := struct {
Settings []json.RawMessage `json:"settings"`
}{}
if err := json.Unmarshal(bytes, &config); err != nil {
return nil, nil, err
}
for _, s := range config.Settings {
switch models.Provider(s.Provider) {
if err := json.Unmarshal(bytes, &rawConfig); err != nil {
return nil, nil, err
}
verifier := verification.NewVerifier()
allSettings = make([]settings.Settings, len(config.CommonSettings))
for i, s := range config.CommonSettings {
if s.Delay != nil {
warnings = append(warnings, "per record delay is not supported anymore and will be ignored")
}
if s.IPMethod != nil {
warnings = append(warnings, "per record ip method is not supported anymore and will be ignored")
}
provider := models.Provider(s.Provider)
switch provider {
case constants.DREAMHOST, constants.DUCKDNS:
s.Host = "@" // only choice available
if s.Host != "" && s.Host != "@" {
warnings = append(warnings, fmt.Sprintf("Provider %s only supports @ host configurations, forcing host to @", provider))
}
s.Host = "@"
}
ipMethod := models.IPMethod(s.IPMethod)
// Retro compatibility
if ipMethod == constants.GOOGLE {
r.logger.Warn("IP Method %q is no longer valid, please change it. Defaulting it to %s", constants.GOOGLE, constants.CYCLE)
ipMethod = constants.CYCLE
if len(s.Host) == 0 {
return nil, nil, fmt.Errorf("host cannot be empty")
}
if !verifier.MatchDomain(s.Domain) {
return nil, nil, fmt.Errorf("invalid domain name format %q", s.Domain)
}
ipVersion := models.IPVersion(s.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4 // default
ipVersion = constants.IPv4OrIPv6 // default
}
setting := models.Settings{
Provider: models.Provider(s.Provider),
Domain: s.Domain,
Host: s.Host,
IPMethod: ipMethod,
IPVersion: ipVersion,
Delay: time.Second * time.Duration(s.Delay),
NoDNSLookup: s.NoDNSLookup,
Password: s.Password,
Key: s.Key,
Secret: s.Secret,
Token: s.Token,
Email: s.Email,
Username: s.Username,
UserServiceKey: s.UserServiceKey,
ZoneIdentifier: s.ZoneIdentifier,
Identifier: s.Identifier,
Proxied: s.Proxied,
TTL: s.TTL,
if err := settingsIPVersionChecks(ipVersion, provider); err != nil {
return nil, nil, err
}
if err := r.isConsistent(setting); err != nil {
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue
var settingsConstructor settings.Constructor
switch provider {
case constants.CLOUDFLARE:
settingsConstructor = settings.NewCloudflare
case constants.DDNSSDE:
settingsConstructor = settings.NewDdnss
case constants.DNSPOD:
settingsConstructor = settings.NewDNSPod
case constants.DREAMHOST:
settingsConstructor = settings.NewDreamhost
case constants.DUCKDNS:
settingsConstructor = settings.NewDuckdns
case constants.GODADDY:
settingsConstructor = settings.NewGodaddy
case constants.INFOMANIAK:
settingsConstructor = settings.NewInfomaniak
case constants.NAMECHEAP:
settingsConstructor = settings.NewNamecheap
case constants.NOIP:
settingsConstructor = settings.NewNoip
default:
return nil, nil, fmt.Errorf("provider %q is not supported", provider)
}
allSettings[i], err = settingsConstructor(rawConfig.Settings[i], s.Domain, s.Host, ipVersion, s.NoDNSLookup)
if err != nil {
return nil, nil, err
}
settings = append(settings, setting)
}
if len(settings) == 0 {
return nil, warnings, fmt.Errorf("no settings found in config.json")
if len(allSettings) == 0 {
warnings = append(warnings, "no settings found in config.json")
}
return settings, warnings, nil
return allSettings, warnings, nil
}

View File

@@ -1,30 +1,50 @@
package params
import (
"fmt"
"io/ioutil"
"net/url"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/settings"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/params"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/verification"
)
const https = "https"
type Reader interface {
GetSettings(filePath string) (settings []models.Settings, warnings []string, err error)
GetDataDir(currentDir string) (string, error)
GetListeningPort() (listeningPort, warning string, err error)
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error)
GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error)
GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error)
GetDelay(setters ...libparams.GetEnvSetter) (duration time.Duration, err error)
GetExeDir() (dir string, err error)
// JSON
GetSettings(filePath string) (allSettings []settings.Settings, warnings []string, err error)
// Core
GetPeriod() (period time.Duration, warnings []string, err error)
GetIPMethod() (method models.IPMethod, err error)
GetIPv4Method() (method models.IPMethod, err error)
GetIPv6Method() (method models.IPMethod, err error)
GetHTTPTimeout() (duration time.Duration, err error)
// File paths
GetExeDir() (dir string, err error)
GetDataDir(currentDir string) (string, error)
// Web UI
GetListeningPort() (listeningPort, warning string, err error)
GetRootURL() (rootURL string, err error)
// Backup
GetBackupPeriod() (duration time.Duration, err error)
GetBackupDirectory() (directory string, err error)
// Other
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
GetGotifyURL() (URL *url.URL, err error)
GetGotifyToken() (token string, err error)
// Version getters
GetVersion() string
GetBuildDate() string
@@ -34,7 +54,6 @@ type Reader interface {
type reader struct {
envParams libparams.EnvParams
verifier verification.Verifier
logger logging.Logger
readFile func(filename string) ([]byte, error)
}
@@ -42,7 +61,6 @@ func NewReader(logger logging.Logger) Reader {
return &reader{
envParams: libparams.NewEnvParams(),
verifier: verification.NewVerifier(),
logger: logger,
readFile: ioutil.ReadFile,
}
}
@@ -61,26 +79,107 @@ func (r *reader) GetLoggerConfig() (encoding logging.Encoding, level logging.Lev
return r.envParams.GetLoggerConfig()
}
func (r *reader) GetGotifyURL(setters ...libparams.GetEnvSetter) (url *url.URL, err error) {
func (r *reader) GetGotifyURL() (url *url.URL, err error) {
return r.envParams.GetGotifyURL()
}
func (r *reader) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
func (r *reader) GetGotifyToken() (token string, err error) {
return r.envParams.GetGotifyToken()
}
func (r *reader) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
func (r *reader) GetRootURL() (rootURL string, err error) {
return r.envParams.GetRootURL()
}
func (r *reader) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, err error) {
func (r *reader) GetPeriod() (period time.Duration, warnings []string, err error) {
// Backward compatibility
n, err := r.envParams.GetEnvInt("DELAY", libparams.Compulsory()) // TODO change to PERIOD
if err == nil { // integer only, treated as seconds
r.logger.Warn("The value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n)
return time.Duration(n) * time.Second, nil
n, err := r.envParams.GetEnvInt("DELAY", libparams.Compulsory())
if err == nil { // integer only, treated as seconds
return time.Duration(n) * time.Second,
[]string{
"the environment variable DELAY should be changed to PERIOD",
fmt.Sprintf("the value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n),
}, nil
}
return r.envParams.GetDuration("DELAY", setters...)
period, err = r.envParams.GetDuration("DELAY", libparams.Compulsory())
if err == nil {
return period,
[]string{
"the environment variable DELAY should be changed to PERIOD",
}, nil
}
period, err = r.envParams.GetDuration("PERIOD", libparams.Default("10m"))
return period, nil, err
}
func (r *reader) GetIPMethod() (method models.IPMethod, err error) {
s, err := r.envParams.GetEnv("IP_METHOD", params.Default("cycle"))
if err != nil {
return method, err
}
for _, choice := range constants.IPMethods() {
if choice.Name == s {
return choice, nil
}
}
url, err := url.Parse(s)
if err != nil || url == nil || url.Scheme != https {
return method, fmt.Errorf("ip method %q is not valid", s)
}
return models.IPMethod{
Name: s,
URL: s,
IPv4: true,
IPv6: true,
}, nil
}
func (r *reader) GetIPv4Method() (method models.IPMethod, err error) {
s, err := r.envParams.GetEnv("IPV4_METHOD", params.Default("cycle"))
if err != nil {
return method, err
}
for _, choice := range constants.IPMethods() {
if choice.Name == s {
if s != "cycle" && !choice.IPv4 {
return method, fmt.Errorf("ip method %s does not support IPv4", s)
}
return choice, nil
}
}
url, err := url.Parse(s)
if err != nil || url == nil || url.Scheme != https {
return method, fmt.Errorf("ipv4 method %q is not valid", s)
}
return models.IPMethod{
Name: s,
URL: s,
IPv4: true,
}, nil
}
func (r *reader) GetIPv6Method() (method models.IPMethod, err error) {
s, err := r.envParams.GetEnv("IPV6_METHOD", params.Default("cycle"))
if err != nil {
return method, err
}
for _, choice := range constants.IPMethods() {
if choice.Name == s {
if s != "cycle" && !choice.IPv6 {
return method, fmt.Errorf("ip method %s does not support IPv6", s)
}
return choice, nil
}
}
url, err := url.Parse(s)
if err != nil || url == nil || url.Scheme != https {
return method, fmt.Errorf("ipv6 method %q is not valid", s)
}
return models.IPMethod{
Name: s,
URL: s,
IPv4: true,
}, nil
}
func (r *reader) GetExeDir() (dir string, err error) {

66
internal/records/html.go Normal file
View File

@@ -0,0 +1,66 @@
package records
import (
"fmt"
"strings"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
func (r *Record) HTML(now time.Time) models.HTMLRow {
const NotAvailable = "N/A"
row := r.Settings.HTML()
message := r.Message
if r.Status == constants.UPTODATE {
message = "no IP change for " + r.History.GetDurationSinceSuccess(now)
}
if len(message) > 0 {
message = fmt.Sprintf("(%s)", message)
}
if len(r.Status) == 0 {
row.Status = NotAvailable
} else {
row.Status = models.HTML(fmt.Sprintf("%s %s, %s",
convertStatus(r.Status),
message,
time.Since(r.Time).Round(time.Second).String()+" ago"))
}
currentIP := r.History.GetCurrentIP()
if currentIP != nil {
row.CurrentIP = models.HTML(`<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>")
} else {
row.CurrentIP = NotAvailable
}
previousIPs := r.History.GetPreviousIPs()
row.PreviousIPs = NotAvailable
if len(previousIPs) > 0 {
var previousIPsStr []string
const maxPreviousIPs = 2
for i, previousIP := range previousIPs {
if i == maxPreviousIPs {
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
break
}
previousIPsStr = append(previousIPsStr, previousIP.String())
}
row.PreviousIPs = models.HTML(strings.Join(previousIPsStr, ", "))
}
return row
}
func convertStatus(status models.Status) models.HTML {
switch status {
case constants.SUCCESS:
return `<font color="green"><b>Success</b></font>`
case constants.FAIL:
return `<font color="red"><b>Failure</b></font>`
case constants.UPTODATE:
return `<font color="#00CC66"><b>Up to date</b></font>`
case constants.UPDATING:
return `<font color="orange"><b>Updating</b></font>`
default:
return "Unknown status"
}
}

View File

@@ -1,21 +1,24 @@
package models
package records
import (
"fmt"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/settings"
)
// Record contains all the information to update and display a DNS record
type Record struct { // internal
Settings Settings // fixed
History History // past information
Status Status
Settings settings.Settings // fixed
History models.History // past information
Status models.Status
Message string
Time time.Time
}
// NewRecord returns a new Record with settings and some history
func NewRecord(settings Settings, events []HistoryEvent) Record {
// New returns a new Record with settings and some history
func New(settings settings.Settings, events []models.HistoryEvent) Record {
return Record{
Settings: settings,
History: events,

View File

@@ -0,0 +1,199 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type cloudflare struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
key string
token string
email string
userServiceKey string
zoneIdentifier string
identifier string
proxied bool
ttl uint
}
func NewCloudflare(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Key string `json:"key"`
Token string `json:"token"`
Email string `json:"email"`
UserServiceKey string `json:"user_service_key"`
ZoneIdentifier string `json:"zone_identifier"`
Identifier string `json:"identifier"`
Proxied bool `json:"proxied"`
TTL uint `json:"ttl"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
c := &cloudflare{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
key: extraSettings.Key,
token: extraSettings.Token,
email: extraSettings.Email,
userServiceKey: extraSettings.UserServiceKey,
zoneIdentifier: extraSettings.ZoneIdentifier,
identifier: extraSettings.Identifier,
proxied: extraSettings.Proxied,
ttl: extraSettings.TTL,
}
if err := c.isValid(); err != nil {
return nil, err
}
return c, nil
}
func (c *cloudflare) isValid() error {
switch {
case len(c.key) > 0: // email and key must be provided
switch {
case !constants.MatchCloudflareKey(c.key):
return fmt.Errorf("invalid key format")
case !verification.NewVerifier().MatchEmail(c.email):
return fmt.Errorf("invalid email format")
}
case len(c.userServiceKey) > 0: // only user service key
if !constants.MatchCloudflareKey(c.key) {
return fmt.Errorf("invalid user service key format")
}
default: // API token only
if !constants.MatchCloudflareToken(c.token) {
return fmt.Errorf("invalid API token key format")
}
}
switch {
case len(c.zoneIdentifier) == 0:
return fmt.Errorf("zone identifier cannot be empty")
case len(c.identifier) == 0:
return fmt.Errorf("identifier cannot be empty")
case c.ttl == 0:
return fmt.Errorf("TTL cannot be left to 0")
}
return nil
}
func (c *cloudflare) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Cloudflare]", c.domain, c.host)
}
func (c *cloudflare) Domain() string {
return c.domain
}
func (c *cloudflare) Host() string {
return c.host
}
func (c *cloudflare) IPVersion() models.IPVersion {
return c.ipVersion
}
func (c *cloudflare) DNSLookup() bool {
return c.dnsLookup
}
func (c *cloudflare) BuildDomainName() string {
return buildDomainName(c.host, c.domain)
}
func (c *cloudflare) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", c.BuildDomainName(), c.BuildDomainName())),
Host: models.HTML(c.Host()),
Provider: "<a href=\"https://www.cloudflare.com\">Cloudflare</a>",
IPVersion: models.HTML(c.ipVersion),
}
}
func (c *cloudflare) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
type cloudflarePutBody struct {
Type string `json:"type"` // forced to A
Name string `json:"name"` // DNS record name i.e. example.com
Content string `json:"content"` // ip address
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
TTL uint `json:"ttl"`
}
u := url.URL{
Scheme: "https",
Host: "api.cloudflare.com",
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", c.zoneIdentifier, c.identifier),
}
r, err := network.BuildHTTPPut(
u.String(),
cloudflarePutBody{
Type: "A",
Name: c.host,
Content: ip.String(),
Proxied: c.proxied,
TTL: c.ttl,
},
)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
switch {
case len(c.token) > 0:
r.Header.Set("Authorization", "Bearer "+c.token)
case len(c.userServiceKey) > 0:
r.Header.Set("X-Auth-User-Service-Key", c.userServiceKey)
case len(c.email) > 0 && len(c.key) > 0:
r.Header.Set("X-Auth-Email", c.email)
r.Header.Set("X-Auth-Key", c.key)
default:
return nil, fmt.Errorf("email and key are both unset and user service key is not set and no token was provided")
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status > http.StatusUnsupportedMediaType {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedJSON struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result struct {
Content string `json:"content"`
} `json:"result"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return nil, err
} else if !parsedJSON.Success {
var errStr string
for _, e := range parsedJSON.Errors {
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
}
return nil, fmt.Errorf(errStr)
}
newIP = net.ParseIP(parsedJSON.Result.Content)
if newIP == nil {
return nil, fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
} else if !newIP.Equal(ip) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

142
internal/settings/ddnss.go Normal file
View File

@@ -0,0 +1,142 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
//nolint:maligned
type ddnss struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
useProviderIP bool
}
func NewDdnss(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
d := &ddnss{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *ddnss) isValid() error {
switch {
case len(d.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(d.password) == 0:
return fmt.Errorf("password cannot be empty")
case d.host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func (d *ddnss) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Ddnss]", d.domain, d.host)
}
func (d *ddnss) Domain() string {
return d.domain
}
func (d *ddnss) Host() string {
return d.host
}
func (d *ddnss) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *ddnss) DNSLookup() bool {
return d.dnsLookup
}
func (d *ddnss) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *ddnss) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://ddnss.de/\">DDNSS.de</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *ddnss) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "www.ddnss.de",
Path: "/upd.php",
}
values := url.Values{}
values.Set("user", d.username)
values.Set("pwd", d.password)
fqdn := d.domain
if d.host != "@" {
fqdn = d.host + "." + d.domain
}
values.Set("host", fqdn)
if !d.useProviderIP {
if ip.To4() == nil { // ipv6
values.Set("ip6", ip.String())
} else {
values.Set("ip", ip.String())
}
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
s := string(content)
if status != http.StatusOK {
return nil, fmt.Errorf("received status %d with message: %s", status, s)
}
switch {
case strings.Contains(s, "badysys"):
return nil, fmt.Errorf("ddnss.de: invalid system parameter")
case strings.Contains(s, "badauth"):
return nil, fmt.Errorf("ddnss.de: bad authentication")
case strings.Contains(s, "notfqdn"):
return nil, fmt.Errorf("ddnss.de: hostname %q does not exist", fqdn)
case strings.Contains(s, "Updated 1 hostname"):
return ip, nil
default:
return nil, fmt.Errorf("unknown response received from ddnss.de: %s", s)
}
}

177
internal/settings/dnspod.go Normal file
View File

@@ -0,0 +1,177 @@
package settings
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
type dnspod struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
token string
}
func NewDNSPod(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Token string `json:"token"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
d := &dnspod{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
token: extraSettings.Token,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *dnspod) isValid() error {
if len(d.token) == 0 {
return fmt.Errorf("token cannot be empty")
}
return nil
}
func (d *dnspod) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: DNSPod]", d.domain, d.host)
}
func (d *dnspod) Domain() string {
return d.domain
}
func (d *dnspod) Host() string {
return d.host
}
func (d *dnspod) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *dnspod) DNSLookup() bool {
return d.dnsLookup
}
func (d *dnspod) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *dnspod) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://www.dnspod.cn/\">DNSPod</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *dnspod) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
if ip == nil {
return nil, fmt.Errorf("IP address was not given to updater")
}
u := url.URL{
Scheme: "https",
Host: "dnsapi.cn",
Path: "/Record.List",
}
values := url.Values{}
values.Set("login_token", d.token)
values.Set("format", "json")
values.Set("domain", d.domain)
values.Set("length", "200")
values.Set("sub_domain", d.host)
values.Set("record_type", "A")
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var recordResp struct {
Records []struct {
ID string `json:"id"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Line string `json:"line"`
} `json:"records"`
}
if err := json.Unmarshal(content, &recordResp); err != nil {
return nil, err
}
var recordID, recordLine string
for _, record := range recordResp.Records {
if record.Type == "A" && record.Name == d.host {
receivedIP := net.ParseIP(record.Value)
if ip.Equal(receivedIP) {
return ip, nil
}
recordID = record.ID
recordLine = record.Line
break
}
}
if len(recordID) == 0 {
return nil, fmt.Errorf("record not found")
}
u.Path = "/Record.Ddns"
values = url.Values{}
values.Set("login_token", d.token)
values.Set("format", "json")
values.Set("domain", d.domain)
values.Set("record_id", recordID)
values.Set("value", ip.String())
values.Set("record_line", recordLine)
values.Set("sub_domain", d.host)
u.RawQuery = values.Encode()
r, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var ddnsResp struct {
Record struct {
ID int64 `json:"id"`
Value string `json:"value"`
Name string `json:"name"`
} `json:"record"`
}
if err := json.Unmarshal(content, &ddnsResp); err != nil {
return nil, err
}
receivedIP := net.ParseIP(ddnsResp.Record.Value)
if !ip.Equal(receivedIP) {
return nil, fmt.Errorf("ip not set")
}
return ip, nil
}

View File

@@ -1,4 +1,4 @@
package update
package settings
import (
"encoding/json"
@@ -8,11 +8,113 @@ import (
"net/url"
"github.com/google/uuid"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
type dreamhost struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
key string
}
func NewDreamhost(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Key string `json:"key"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
d := &dreamhost{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
key: extraSettings.Key,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *dreamhost) isValid() error {
switch {
case !constants.MatchDreamhostKey(d.key):
return fmt.Errorf("invalid key format")
case d.host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func (d *dreamhost) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Dreamhost]", d.domain, d.host)
}
func (d *dreamhost) Domain() string {
return d.domain
}
func (d *dreamhost) Host() string {
return d.host
}
func (d *dreamhost) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *dreamhost) DNSLookup() bool {
return d.dnsLookup
}
func (d *dreamhost) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *dreamhost) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
const success = "success"
func (d *dreamhost) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
if ip == nil {
return nil, fmt.Errorf("IP address was not given to updater")
}
records, err := listDreamhostRecords(client, d.key)
if err != nil {
return nil, err
}
var oldIP net.IP
for _, data := range records.Data {
if data.Type == "A" && data.Record == d.BuildDomainName() {
if data.Editable == "0" {
return nil, fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return ip, nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
if err := removeDreamhostRecord(client, d.key, d.domain, oldIP); err != nil {
return nil, err
}
}
return ip, addDreamhostRecord(client, d.key, d.domain, ip)
}
type (
dreamHostRecords struct {
Result string `json:"result"`
@@ -124,32 +226,3 @@ func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) er
}
return nil
}
func updateDreamhost(client network.Client, domain, key, domainName string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
records, err := listDreamhostRecords(client, key)
if err != nil {
return err
}
var oldIP net.IP
for _, data := range records.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
if err := removeDreamhostRecord(client, key, domain, oldIP); err != nil {
return err
}
}
return addDreamhostRecord(client, key, domain, ip)
}

View File

@@ -0,0 +1,138 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type duckdns struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
token string
useProviderIP bool
}
func NewDuckdns(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Token string `json:"token"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
d := &duckdns{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
token: extraSettings.Token,
useProviderIP: extraSettings.UseProviderIP,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *duckdns) isValid() error {
switch {
case !constants.MatchDuckDNSToken(d.token):
return fmt.Errorf("invalid token format")
case d.host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func (d *duckdns) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Duckdns]", d.domain, d.host)
}
func (d *duckdns) Domain() string {
return d.domain
}
func (d *duckdns) Host() string {
return d.host
}
func (d *duckdns) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *duckdns) DNSLookup() bool {
return d.dnsLookup
}
func (d *duckdns) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *duckdns) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://duckdns.org\">DuckDNS</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *duckdns) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "www.duckdns.org",
Path: "/update",
}
values := url.Values{}
values.Set("verbose", "true")
values.Set("domains", d.domain)
values.Set("token", d.token)
u.RawQuery = values.Encode()
if !d.useProviderIP {
values.Set("ip", ip.String())
}
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
s := string(content)
switch {
case len(s) < 2:
return nil, fmt.Errorf("response %q is too short", s)
case s[0:2] == "KO":
return nil, fmt.Errorf("invalid domain token combination")
case s[0:2] == "OK":
ips := verification.NewVerifier().SearchIPv4(s)
if ips == nil {
return nil, fmt.Errorf("no IP address in response")
}
newIP = net.ParseIP(ips[0])
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
}
if ip != nil && !newIP.Equal(ip) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
default:
return nil, fmt.Errorf("invalid response %q", s)
}
}

View File

@@ -0,0 +1,123 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
netlib "github.com/qdm12/golibs/network"
)
type godaddy struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
key string
secret string
}
func NewGodaddy(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Key string `json:"key"`
Secret string `json:"secret"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
g := &godaddy{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
key: extraSettings.Key,
secret: extraSettings.Secret,
}
if err := g.isValid(); err != nil {
return nil, err
}
return g, nil
}
func (g *godaddy) isValid() error {
switch {
case !constants.MatchGodaddyKey(g.key):
return fmt.Errorf("invalid key format")
case !constants.MatchGodaddySecret(g.secret):
return fmt.Errorf("invalid secret format")
}
return nil
}
func (g *godaddy) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Godaddy]", g.domain, g.host)
}
func (g *godaddy) Domain() string {
return g.domain
}
func (g *godaddy) Host() string {
return g.host
}
func (g *godaddy) IPVersion() models.IPVersion {
return g.ipVersion
}
func (g *godaddy) DNSLookup() bool {
return g.dnsLookup
}
func (g *godaddy) BuildDomainName() string {
return buildDomainName(g.host, g.domain)
}
func (g *godaddy) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", g.BuildDomainName(), g.BuildDomainName())),
Host: models.HTML(g.Host()),
Provider: "<a href=\"https://godaddy.com\">GoDaddy</a>",
IPVersion: models.HTML(g.ipVersion),
}
}
func (g *godaddy) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
if ip == nil {
return nil, fmt.Errorf("IP address was not given to updater")
}
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
u := url.URL{
Scheme: "https",
Host: "api.godaddy.com",
Path: fmt.Sprintf("/v1/domains/%s/records/A/%s", g.domain, g.host),
}
r, err := network.BuildHTTPPut(u.String(), []goDaddyPutBody{{ip.String()}})
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
r.Header.Set("Authorization", "sso-key "+g.key+":"+g.secret)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
var parsedJSON struct {
Message string `json:"message"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return nil, err
} else if len(parsedJSON.Message) > 0 {
return nil, fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
}
return nil, fmt.Errorf("HTTP status %d", status)
}
return ip, nil
}

View File

@@ -0,0 +1,155 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
//nolint:maligned
type infomaniak struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
useProviderIP bool
}
func NewInfomaniak(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
i := &infomaniak{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := i.isValid(); err != nil {
return nil, err
}
return i, nil
}
func (i *infomaniak) isValid() error {
switch {
case len(i.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(i.password) == 0:
return fmt.Errorf("password cannot be empty")
case i.host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func (i *infomaniak) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Infomaniak]", i.domain, i.host)
}
func (i *infomaniak) Domain() string {
return i.domain
}
func (i *infomaniak) Host() string {
return i.host
}
func (i *infomaniak) IPVersion() models.IPVersion {
return i.ipVersion
}
func (i *infomaniak) DNSLookup() bool {
return i.dnsLookup
}
func (i *infomaniak) BuildDomainName() string {
return buildDomainName(i.host, i.domain)
}
func (i *infomaniak) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", i.BuildDomainName(), i.BuildDomainName())),
Host: models.HTML(i.Host()),
Provider: "<a href=\"https://www.infomaniak.com/\">Infomaniak</a>",
IPVersion: models.HTML(i.ipVersion),
}
}
func (i *infomaniak) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "infomaniak.com",
Path: "/nic/update",
User: url.UserPassword(i.username, i.password),
}
values := url.Values{}
values.Set("hostname", i.domain)
if i.host != "@" {
values.Set("hostname", i.host+"."+i.domain)
}
if !i.useProviderIP {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
s := string(content)
switch status {
case http.StatusOK:
switch {
case strings.HasPrefix(s, "good "):
newIP = net.ParseIP(s[5:])
if newIP == nil {
return nil, fmt.Errorf("no received IP in response %q", s)
} else if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
}
return newIP, nil
case strings.HasPrefix(s, "nochg "):
newIP = net.ParseIP(s[6:])
if newIP == nil {
return nil, fmt.Errorf("no received IP in response %q", s)
} else if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
}
return newIP, nil
default:
return nil, fmt.Errorf("ok status but unknown response %q", s)
}
case http.StatusBadRequest:
switch s {
case "nohost":
return nil, fmt.Errorf("infomaniak.com: host %q does not exist for domain %q", i.host, i.domain)
case "badauth":
return nil, fmt.Errorf("infomaniak.com: bad authentication")
default:
return nil, fmt.Errorf("infomaniak.com: bad request: %s", s)
}
default:
return nil, fmt.Errorf("received status %d with message: %s", status, s)
}
}

View File

@@ -0,0 +1,133 @@
package settings
import (
"encoding/json"
"encoding/xml"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
//nolint:maligned
type namecheap struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
password string
useProviderIP bool
}
func NewNamecheap(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
n := &namecheap{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := n.isValid(); err != nil {
return nil, err
}
return n, nil
}
func (n *namecheap) isValid() error {
if !constants.MatchNamecheapPassword(n.password) {
return fmt.Errorf("invalid password format")
}
return nil
}
func (n *namecheap) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Namecheap]", n.domain, n.host)
}
func (n *namecheap) Domain() string {
return n.domain
}
func (n *namecheap) Host() string {
return n.host
}
func (n *namecheap) IPVersion() models.IPVersion {
return n.ipVersion
}
func (n *namecheap) DNSLookup() bool {
return n.dnsLookup
}
func (n *namecheap) BuildDomainName() string {
return buildDomainName(n.host, n.domain)
}
func (n *namecheap) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", n.BuildDomainName(), n.BuildDomainName())),
Host: models.HTML(n.Host()),
Provider: "<a href=\"https://namecheap.com\">Namecheap</a>",
IPVersion: models.HTML(n.ipVersion),
}
}
func (n *namecheap) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "dynamicdns.park-your-domain.com",
Path: "/update",
}
values := url.Values{}
values.Set("host", n.host)
values.Set("domain", n.domain)
values.Set("password", n.password)
if !n.useProviderIP {
values.Set("ip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedXML struct {
Errors struct {
Error string `xml:"Err1"`
} `xml:"errors"`
IP string `xml:"IP"`
}
err = xml.Unmarshal(content, &parsedXML)
if err != nil {
return nil, err
} else if parsedXML.Errors.Error != "" {
return nil, fmt.Errorf(parsedXML.Errors.Error)
}
newIP = net.ParseIP(parsedXML.IP)
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

152
internal/settings/noip.go Normal file
View File

@@ -0,0 +1,152 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/models"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type noip struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
useProviderIP bool
}
func NewNoip(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
n := &noip{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := n.isValid(); err != nil {
return nil, err
}
return n, nil
}
func (n *noip) isValid() error {
switch {
case len(n.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(n.username) > 50:
return fmt.Errorf("username cannot be longer than 50 characters")
case len(n.password) == 0:
return fmt.Errorf("password cannot be empty")
case n.host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func (n *noip) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Noip]", n.domain, n.host)
}
func (n *noip) Domain() string {
return n.domain
}
func (n *noip) Host() string {
return n.host
}
func (n *noip) DNSLookup() bool {
return n.dnsLookup
}
func (n *noip) IPVersion() models.IPVersion {
return n.ipVersion
}
func (n *noip) BuildDomainName() string {
return buildDomainName(n.host, n.domain)
}
func (n *noip) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", n.BuildDomainName(), n.BuildDomainName())),
Host: models.HTML(n.Host()),
Provider: "<a href=\"https://www.noip.com/\">NoIP</a>",
IPVersion: models.HTML(n.ipVersion),
}
}
func (n *noip) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "dynupdate.no-ip.com",
Path: "/nic/update",
User: url.UserPassword(n.username, n.password),
}
values := url.Values{}
values.Set("hostname", n.BuildDomainName())
if !n.useProviderIP {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
s := string(content)
switch s {
case "":
return nil, fmt.Errorf("HTTP status %d", status)
case "911":
return nil, fmt.Errorf("NoIP's internal server error 911")
case "abuse":
return nil, fmt.Errorf("username is banned due to abuse")
case "!donator":
return nil, fmt.Errorf("user has not this extra feature")
case "badagent":
return nil, fmt.Errorf("user agent is banned")
case "badauth":
return nil, fmt.Errorf("invalid username password combination")
case "nohost":
return nil, fmt.Errorf("hostname does not exist")
}
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
ips := verification.NewVerifier().SearchIPv4(s)
if ips == nil {
return nil, fmt.Errorf("no IP address in response")
}
newIP = net.ParseIP(ips[0])
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}
return nil, fmt.Errorf("invalid response %q", s)
}

View File

@@ -0,0 +1,33 @@
package settings
import (
"encoding/json"
"net"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
)
type Settings interface {
String() string
Domain() string
Host() string
BuildDomainName() string
HTML() models.HTMLRow
DNSLookup() bool
IPVersion() models.IPVersion
Update(client network.Client, ip net.IP) (newIP net.IP, err error)
}
type Constructor func(data json.RawMessage, domain string, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error)
func buildDomainName(host, domain string) string {
switch host {
case "@":
return domain
case "*":
return "any." + domain
default:
return host + "." + domain
}
}

View File

@@ -1,51 +0,0 @@
package trigger
import (
"context"
"time"
"github.com/qdm12/ddns-updater/internal/update"
)
// StartUpdates starts periodic updates
func StartUpdates(ctx context.Context, updater update.Updater, idPeriodMapping map[int]time.Duration, onError func(err error)) (forceUpdate func()) {
errors := make(chan error)
triggers := make([]chan struct{}, len(idPeriodMapping))
for id, period := range idPeriodMapping {
triggers[id] = make(chan struct{})
go func(id int, period time.Duration) {
ticker := time.NewTicker(period)
defer ticker.Stop()
for {
select {
case <-triggers[id]:
if err := updater.Update(id); err != nil {
errors <- err
}
case <-ticker.C:
if err := updater.Update(id); err != nil {
errors <- err
}
case <-ctx.Done():
return
}
}
}(id, period)
}
// collects errors only
go func() {
for {
select {
case err := <-errors:
onError(err)
case <-ctx.Done():
return
}
}
}()
return func() {
for i := range triggers {
triggers[i] <- struct{}{}
}
}
}

View File

@@ -1,87 +0,0 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host, email, key, userServiceKey, token string, proxied bool, ttl uint, ip net.IP) (err error) {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
type cloudflarePutBody struct {
Type string `json:"type"` // forced to A
Name string `json:"name"` // DNS record name i.e. example.com
Content string `json:"content"` // ip address
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
TTL uint `json:"ttl"`
}
u := url.URL{
Scheme: "https",
Host: "api.cloudflare.com",
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", zoneIdentifier, identifier),
}
r, err := network.BuildHTTPPut(
u.String(),
cloudflarePutBody{
Type: "A",
Name: host,
Content: ip.String(),
Proxied: proxied,
TTL: ttl,
},
)
if err != nil {
return err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
switch {
case len(token) > 0:
r.Header.Set("Authorization", "Bearer "+token)
case len(userServiceKey) > 0:
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
case len(email) > 0 && len(key) > 0:
r.Header.Set("X-Auth-Email", email)
r.Header.Set("X-Auth-Key", key)
default:
return fmt.Errorf("email and key are both unset and user service key is not set and no token was provided")
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status > http.StatusUnsupportedMediaType {
return fmt.Errorf("HTTP status %d", status)
}
var parsedJSON struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result struct {
Content string `json:"content"`
} `json:"result"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return err
} else if !parsedJSON.Success {
var errStr string
for _, e := range parsedJSON.Errors {
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
}
return fmt.Errorf(errStr)
}
newIP := net.ParseIP(parsedJSON.Result.Content)
if newIP == nil {
return fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
} else if !newIP.Equal(ip) {
return fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return nil
}

34
internal/update/cycle.go Normal file
View File

@@ -0,0 +1,34 @@
package update
import (
"sync"
"github.com/qdm12/ddns-updater/internal/models"
)
type cycler interface {
next() models.IPMethod
}
type cyclerImpl struct {
sync.Mutex
counter int
methods []models.IPMethod
}
func newCycler(methods []models.IPMethod) cycler {
return &cyclerImpl{
methods: methods,
}
}
func (c *cyclerImpl) next() models.IPMethod {
c.Lock()
defer c.Unlock()
method := c.methods[c.counter]
c.counter++
if c.counter == len(c.methods) {
c.counter = 0
}
return method
}

View File

@@ -0,0 +1,64 @@
package update
import (
"sync"
"testing"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_newCycler(t *testing.T) {
t.Parallel()
ipMethods := []models.IPMethod{
{Name: "a"}, {Name: "b"},
}
c := newCycler(ipMethods)
require.NotNil(t, c)
ipMethod := c.next()
assert.Equal(t, ipMethod, models.IPMethod{Name: "a"})
}
func Test_next(t *testing.T) {
t.Parallel()
c := &cyclerImpl{
methods: []models.IPMethod{
{Name: "a"}, {Name: "b"},
},
}
var m models.IPMethod
m = c.next()
assert.Equal(t, m, models.IPMethod{Name: "a"})
m = c.next()
assert.Equal(t, m, models.IPMethod{Name: "b"})
m = c.next()
assert.Equal(t, m, models.IPMethod{Name: "a"})
}
func Test_next_RaceCondition(t *testing.T) {
// Run with -race flag
t.Parallel()
const workers = 5
const loopSize = 101
c := &cyclerImpl{
methods: []models.IPMethod{
{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
},
}
ready := make(chan struct{})
wg := &sync.WaitGroup{}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
<-ready
for i := 0; i < loopSize; i++ {
c.next()
}
wg.Done()
}()
}
close(ready)
wg.Wait()
assert.Equal(t, (workers*loopSize)%len(c.methods), c.counter)
}

View File

@@ -1,60 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/golibs/network"
)
func updateDDNSS(client network.Client, domain, host, username, password string, ip net.IP) error {
u := url.URL{
Scheme: "https",
Host: "www.ddnss.de",
Path: "/upd.php",
}
values := url.Values{}
values.Set("user", username)
values.Set("pwd", password)
fqdn := domain
if host != "@" {
fqdn = host + "." + domain
}
values.Set("host", fqdn)
if ip != nil {
if ip.To4() == nil { // ipv6
values.Set("ip6", ip.String())
} else {
values.Set("ip", ip.String())
}
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
}
s := string(content)
if status != http.StatusOK {
return fmt.Errorf("received status %d with message: %s", status, s)
}
switch {
case strings.Contains(s, "badysys"):
return fmt.Errorf("ddnss.de: invalid system parameter")
case strings.Contains(s, "badauth"):
return fmt.Errorf("ddnss.de: bad authentication")
case strings.Contains(s, "notfqdn"):
return fmt.Errorf("ddnss.de: hostname %q does not exist", fqdn)
case strings.Contains(s, "Updated 1 hostname"):
return nil
default:
return fmt.Errorf("unknown response received from ddnss.de: %s", s)
}
}

View File

@@ -1,108 +0,0 @@
package update
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/golibs/network"
)
func updateDNSPod(client network.Client, domain, host, token string, ip net.IP) (err error) {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
u := url.URL{
Scheme: "https",
Host: "dnsapi.cn",
Path: "/Record.List",
}
values := url.Values{}
values.Set("login_token", token)
values.Set("format", "json")
values.Set("domain", domain)
values.Set("length", "200")
values.Set("sub_domain", host)
values.Set("record_type", "A")
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
if err != nil {
return err
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var recordResp struct {
Records []struct {
ID string `json:"id"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Line string `json:"line"`
} `json:"records"`
}
if err := json.Unmarshal(content, &recordResp); err != nil {
return err
}
var recordID, recordLine string
for _, record := range recordResp.Records {
if record.Type == "A" && record.Name == host {
receivedIP := net.ParseIP(record.Value)
if ip.Equal(receivedIP) {
return nil
}
recordID = record.ID
recordLine = record.Line
break
}
}
if len(recordID) == 0 {
return fmt.Errorf("record not found")
}
u.Path = "/Record.Ddns"
values = url.Values{}
values.Set("login_token", token)
values.Set("format", "json")
values.Set("domain", domain)
values.Set("record_id", recordID)
values.Set("value", ip.String())
values.Set("record_line", recordLine)
values.Set("sub_domain", host)
u.RawQuery = values.Encode()
r, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
if err != nil {
return err
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var ddnsResp struct {
Record struct {
ID int64 `json:"id"`
Value string `json:"value"`
Name string `json:"name"`
} `json:"record"`
}
if err := json.Unmarshal(content, &ddnsResp); err != nil {
return err
}
receivedIP := net.ParseIP(ddnsResp.Record.Value)
if !ip.Equal(receivedIP) {
return fmt.Errorf("ip not set")
}
return nil
}

View File

@@ -1,60 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
func updateDuckDNS(client network.Client, domain, token string, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "www.duckdns.org",
Path: "/update",
}
values := url.Values{}
values.Set("verbose", "true")
values.Set("domains", domain)
values.Set("token", token)
u.RawQuery = values.Encode()
if ip != nil {
values.Set("ip", ip.String())
}
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
s := string(content)
switch {
case len(s) < 2:
return nil, fmt.Errorf("response %q is too short", s)
case s[0:2] == "KO":
return nil, fmt.Errorf("invalid domain token combination")
case s[0:2] == "OK":
ips := verification.NewVerifier().SearchIPv4(s)
if ips == nil {
return nil, fmt.Errorf("no IP address in response")
}
newIP = net.ParseIP(ips[0])
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
}
if ip != nil && !newIP.Equal(ip) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
default:
return nil, fmt.Errorf("invalid response %q", s)
}
}

View File

@@ -1,47 +0,0 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
u := url.URL{
Scheme: "https",
Host: "api.godaddy.com",
Path: fmt.Sprintf("/v1/domains/%s/records/A/%s", domain, host),
}
r, err := network.BuildHTTPPut(u.String(), []goDaddyPutBody{{ip.String()}})
if err != nil {
return err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
var parsedJSON struct {
Message string `json:"message"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return err
} else if len(parsedJSON.Message) > 0 {
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
}
return fmt.Errorf("HTTP status %d", status)
}
return nil
}

View File

@@ -1,73 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/golibs/network"
)
func updateInfomaniak(client network.Client, domain, host, username, password string, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "infomaniak.com",
Path: "/nic/update",
User: url.UserPassword(username, password),
}
values := url.Values{}
values.Set("hostname", domain)
if host != "@" {
values.Set("hostname", host+"."+domain)
}
if ip != nil {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
s := string(content)
switch status {
case http.StatusOK:
switch {
case strings.HasPrefix(s, "good "):
newIP = net.ParseIP(s[5:])
if newIP == nil {
return nil, fmt.Errorf("no received IP in response %q", s)
} else if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
}
return newIP, nil
case strings.HasPrefix(s, "nochg "):
newIP = net.ParseIP(s[6:])
if newIP == nil {
return nil, fmt.Errorf("no received IP in response %q", s)
} else if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
}
return newIP, nil
default:
return nil, fmt.Errorf("ok status but unknown response %q", s)
}
case http.StatusBadRequest:
switch s {
case "nohost":
return nil, fmt.Errorf("infomaniak.com: host %q does not exist for domain %q", host, domain)
case "badauth":
return nil, fmt.Errorf("infomaniak.com: bad authentication")
default:
return nil, fmt.Errorf("infomaniak.com: bad request: %s", s)
}
default:
return nil, fmt.Errorf("received status %d with message: %s", status, s)
}
}

77
internal/update/ip.go Normal file
View File

@@ -0,0 +1,77 @@
package update
import (
"net"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
libnet "github.com/qdm12/golibs/network"
)
const cycle = "cycle"
type IPGetter interface {
IP() (ip net.IP, err error)
IPv4() (ip net.IP, err error)
IPv6() (ip net.IP, err error)
}
type ipGetter struct {
client libnet.Client
ipMethod models.IPMethod
ipv4Method models.IPMethod
ipv6Method models.IPMethod
cyclerIP cycler
cyclerIPv4 cycler
cyclerIPv6 cycler
}
func NewIPGetter(client libnet.Client, ipMethod, ipv4Method, ipv6Method models.IPMethod) IPGetter {
ipMethods := []models.IPMethod{}
ipv4Methods := []models.IPMethod{}
ipv6Methods := []models.IPMethod{}
for _, method := range constants.IPMethods() {
switch {
case method.IPv4 && method.IPv6:
ipMethods = append(ipMethods, method)
case method.IPv4:
ipv4Methods = append(ipv4Methods, method)
case method.IPv6:
ipv6Methods = append(ipv6Methods, method)
}
}
return &ipGetter{
client: client,
ipMethod: ipMethod,
ipv4Method: ipv4Method,
ipv6Method: ipv6Method,
cyclerIP: newCycler(ipMethods),
cyclerIPv4: newCycler(ipv4Methods),
cyclerIPv6: newCycler(ipv6Methods),
}
}
func (i *ipGetter) IP() (ip net.IP, err error) {
method := i.ipMethod
if method.Name == cycle {
method = i.cyclerIP.next()
}
return network.GetPublicIP(i.client, method.URL, constants.IPv4OrIPv6)
}
func (i *ipGetter) IPv4() (ip net.IP, err error) {
method := i.ipv4Method
if method.Name == cycle {
method = i.cyclerIPv4.next()
}
return network.GetPublicIP(i.client, method.URL, constants.IPv4)
}
func (i *ipGetter) IPv6() (ip net.IP, err error) {
method := i.ipv6Method
if method.Name == cycle {
method = i.cyclerIPv6.next()
}
return network.GetPublicIP(i.client, method.URL, constants.IPv6)
}

143
internal/update/ip_test.go Normal file
View File

@@ -0,0 +1,143 @@
package update
import (
"net"
"net/http"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/network/mock_network"
"github.com/stretchr/testify/assert"
)
func Test_NewIPGetter(t *testing.T) {
t.Parallel()
client := network.NewClient(time.Second)
ipMethod := models.IPMethod{Name: "ip"}
ipv4Method := models.IPMethod{Name: "ipv4"}
ipv6Method := models.IPMethod{Name: "ipv6"}
ipGetter := NewIPGetter(client, ipMethod, ipv4Method, ipv6Method)
assert.NotNil(t, ipGetter)
}
func Test_IP(t *testing.T) {
t.Parallel()
tests := map[string]struct {
ipMethod models.IPMethod
mockContent []byte
ip net.IP
}{
"url ipv4": {
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
"url ipv6": {
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
"cycle": {
ipMethod: models.IPMethod{Name: cycle},
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
url := tc.ipMethod.URL
if tc.ipMethod.Name == cycle {
url = "https://diagnostic.opendns.com/myip"
}
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
ig := NewIPGetter(client, tc.ipMethod, models.IPMethod{}, models.IPMethod{})
ip, err := ig.IP()
assert.Nil(t, err)
assert.True(t, tc.ip.Equal(ip))
})
}
}
func Test_IPv4(t *testing.T) {
t.Parallel()
tests := map[string]struct {
ipMethod models.IPMethod
mockContent []byte
ip net.IP
}{
"url": {
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
"cycle": {
ipMethod: models.IPMethod{Name: cycle},
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
url := tc.ipMethod.URL
if tc.ipMethod.Name == cycle {
url = "https://api.ipify.org"
}
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
ig := NewIPGetter(client, models.IPMethod{}, tc.ipMethod, models.IPMethod{})
ip, err := ig.IPv4()
assert.Nil(t, err)
assert.True(t, tc.ip.Equal(ip))
})
}
}
func Test_IPv6(t *testing.T) {
t.Parallel()
tests := map[string]struct {
ipMethod models.IPMethod
mockContent []byte
ip net.IP
}{
"url": {
ipMethod: models.IPMethod{URL: "https://ip6.ddnss.de/meineip.php"},
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
"cycle": {
ipMethod: models.IPMethod{Name: cycle},
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
url := tc.ipMethod.URL
if tc.ipMethod.Name == cycle {
url = "https://api6.ipify.org"
}
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
ig := NewIPGetter(client, models.IPMethod{}, models.IPMethod{}, tc.ipMethod)
ip, err := ig.IPv6()
assert.Nil(t, err)
assert.True(t, tc.ip.Equal(ip))
})
}
}

View File

@@ -1,59 +0,0 @@
package update
import (
"encoding/xml"
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/golibs/network"
)
func updateNamecheap(client network.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "dynamicdns.park-your-domain.com",
Path: "/update",
// User: url.UserPassword(username, password),
}
values := url.Values{}
values.Set("host", host)
values.Set("domain", domain)
values.Set("password", password)
if ip != nil {
values.Set("ip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedXML struct {
Errors struct {
Error string `xml:"Err1"`
} `xml:"errors"`
IP string `xml:"IP"`
}
err = xml.Unmarshal(content, &parsedXML)
if err != nil {
return nil, err
} else if parsedXML.Errors.Error != "" {
return nil, fmt.Errorf(parsedXML.Errors.Error)
}
newIP = net.ParseIP(parsedXML.IP)
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

View File

@@ -1,68 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
func updateNoIP(client network.Client, hostname, username, password string, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "dynupdate.no-ip.com",
Path: "/nic/update",
User: url.UserPassword(username, password),
}
values := url.Values{}
values.Set("hostname", hostname)
if ip != nil {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
s := string(content)
switch s {
case "":
return nil, fmt.Errorf("HTTP status %d", status)
case "911":
return nil, fmt.Errorf("NoIP's internal server error 911")
case "abuse":
return nil, fmt.Errorf("username is banned due to abuse")
case "!donator":
return nil, fmt.Errorf("user has not this extra feature")
case "badagent":
return nil, fmt.Errorf("user agent is banned")
case "badauth":
return nil, fmt.Errorf("invalid username password combination")
case "nohost":
return nil, fmt.Errorf("hostname does not exist")
}
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
ips := verification.NewVerifier().SearchIPv4(s)
if ips == nil {
return nil, fmt.Errorf("no IP address in response")
}
newIP = net.ParseIP(ips[0])
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}
return nil, fmt.Errorf("invalid response %q", s)
}

155
internal/update/run.go Normal file
View File

@@ -0,0 +1,155 @@
package update
import (
"context"
"net"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
librecords "github.com/qdm12/ddns-updater/internal/records"
"github.com/qdm12/golibs/logging"
)
type Runner interface {
Run(ctx context.Context, period time.Duration, records []librecords.Record) (forceUpdate func())
}
type runner struct {
updater Updater
ipGetter IPGetter
logger logging.Logger
timeNow func() time.Time
}
func NewRunner(updater Updater, ipGetter IPGetter, logger logging.Logger, timeNow func() time.Time) Runner {
return &runner{
updater: updater,
ipGetter: ipGetter,
logger: logger,
timeNow: timeNow,
}
}
func readPersistedIPs(records []librecords.Record) (ip, ipv4, ipv6 net.IP) {
for _, record := range records {
switch record.Settings.IPVersion() {
case constants.IPv4OrIPv6:
ip = record.History.GetCurrentIP()
if ip == nil {
ip = net.IP{127, 0, 0, 1}
}
case constants.IPv4:
ipv4 = record.History.GetCurrentIP()
if ipv4 == nil {
ipv4 = net.IP{127, 0, 0, 1}
}
case constants.IPv6:
ipv6 = record.History.GetCurrentIP()
if ipv6 == nil {
ipv6 = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
}
}
if ip != nil && ipv4 != nil && ipv6 != nil {
return ip, ipv4, ipv6
}
}
return ip, ipv4, ipv6
}
func (r *runner) getNewIPs(doIP, doIPv4, doIPv6 bool) (ip, ipv4, ipv6 net.IP, errors []error) {
var err error
if doIP {
ip, err = r.ipGetter.IP()
if err != nil {
errors = append(errors, err)
}
}
if doIPv4 {
ipv4, err = r.ipGetter.IPv4()
if err != nil {
errors = append(errors, err)
}
}
if doIPv6 {
ipv6, err = r.ipGetter.IPv6()
if err != nil {
errors = append(errors, err)
}
}
return ip, ipv4, ipv6, errors
}
func shouldUpdate(ip, newIP net.IP, force bool) bool {
ipVersionDisabled := ip == nil
ipFetchFailed := newIP == nil
ipChanged := !ip.Equal(newIP)
switch {
case ipVersionDisabled, ipFetchFailed:
return false
case ipChanged, force:
return true
default:
return false
}
}
func (r *runner) updateNecessary(records []librecords.Record, ip, ipv4, ipv6 net.IP, force bool) (newIP, newIPv4, newIPv6 net.IP) {
newIP, newIPv4, newIPv6, errors := r.getNewIPs(ip != nil, ipv4 != nil, ipv6 != nil)
for _, err := range errors {
r.logger.Error(err)
}
updateIP := shouldUpdate(ip, newIP, force)
updateIPv4 := shouldUpdate(ipv4, newIPv4, force)
updateIPv6 := shouldUpdate(ipv6, newIPv6, force)
if updateIP && !force {
r.logger.Info("IP address changed from %s to %s", ip, newIP)
}
if updateIPv4 && !force {
r.logger.Info("IPv4 address changed from %s to %s", ipv4, newIPv4)
}
if updateIPv6 && !force {
r.logger.Info("IPv6 address changed from %s to %s", ipv6, newIPv6)
}
for id, record := range records {
now := r.timeNow()
var err error
switch {
case updateIP && record.Settings.IPVersion() == constants.IPv4OrIPv6:
err = r.updater.Update(id, newIP, now)
case updateIPv4 && record.Settings.IPVersion() == constants.IPv4:
err = r.updater.Update(id, newIPv4, now)
case updateIPv6 && record.Settings.IPVersion() == constants.IPv6:
err = r.updater.Update(id, newIPv6, now)
}
if err != nil {
r.logger.Error(err)
}
}
return newIP, newIPv4, newIPv6
}
func (r *runner) Run(ctx context.Context, period time.Duration, records []librecords.Record) (forceUpdate func()) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
timer := time.NewTicker(period)
forceChannel := make(chan struct{})
go func() {
r.logger.Info("reading persisted ips")
ip, ipv4, ipv6 := readPersistedIPs(records)
r.logger.Info("done reading persisted ips")
for {
select {
case <-timer.C:
ip, ipv4, ipv6 = r.updateNecessary(records, ip, ipv4, ipv6, false)
case <-forceChannel:
ip, ipv4, ipv6 = r.updateNecessary(records, ip, ipv4, ipv6, true)
case <-ctx.Done():
timer.Stop()
return
}
}
}()
return func() {
forceChannel <- struct{}{}
}
}

View File

@@ -3,212 +3,71 @@ package update
import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/qdm12/golibs/logging"
libnetwork "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
)
type Updater interface {
Update(id int) error
Update(id int, ip net.IP, now time.Time) (err error)
}
type updater struct {
db data.Database
logger logging.Logger
client libnetwork.Client
notify notifyFunc
verifier verification.Verifier
ipMethods []models.IPMethod
counter int
counterMutex sync.RWMutex
db data.Database
logger logging.Logger
client netlib.Client
notify notifyFunc
}
type notifyFunc func(priority int, messageArgs ...interface{})
func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
func NewUpdater(db data.Database, logger logging.Logger, client netlib.Client, notify notifyFunc) Updater {
return &updater{
db: db,
logger: logger,
client: client,
notify: notify,
verifier: verification.NewVerifier(),
ipMethods: constants.IPMethodExternalChoices(),
db: db,
logger: logger,
client: client,
notify: notify,
}
}
func (u *updater) Update(id int) error {
func (u *updater) Update(id int, ip net.IP, now time.Time) (err error) {
record, err := u.db.Select(id)
if err != nil {
return err
}
record.Time = time.Now()
record.Time = now
record.Status = constants.UPDATING
if err := u.db.Update(id, record); err != nil {
return err
}
status, message, newIP, err := u.update(
record.Settings,
record.History.GetCurrentIP(),
record.History.GetDurationSinceSuccess(time.Now()))
record.Status = status
record.Message = message
if err != nil {
if len(record.Message) == 0 {
var newIP net.IP
record.Status = constants.FAIL
if ip.Equal(record.History.GetCurrentIP()) {
record.Status = constants.UPTODATE
record.Message = fmt.Sprintf("No IP change for %s", record.History.GetDurationSinceSuccess(now))
} else {
newIP, err = record.Settings.Update(u.client, ip)
if err != nil {
record.Message = err.Error()
if updateErr := u.db.Update(id, record); updateErr != nil {
return fmt.Errorf("%s, %s", err, updateErr)
}
return err
}
if updateErr := u.db.Update(id, record); updateErr != nil {
return fmt.Errorf("%s, %s", err, updateErr)
}
return err
record.Status = constants.SUCCESS
record.Message = fmt.Sprintf("changed to %s", ip.String())
}
if newIP != nil {
record.History = append(record.History, models.HistoryEvent{
IP: newIP,
Time: time.Now(),
Time: now,
})
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), message))
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), record.Message))
}
return u.db.Update(id, record) // persists some data if needed (i.e new IP)
}
func (u *updater) update(settings models.Settings, currentIP net.IP, durationSinceSuccess string) (status models.Status, message string, newIP net.IP, err error) {
// Get the public IP address
ip, err := u.getPublicIP(settings.IPMethod, settings.IPVersion) // Note: empty IP means DNS provider provided
if err != nil {
return constants.FAIL, "", nil, err
}
if ip != nil && ip.Equal(currentIP) {
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
}
// Update the record
switch settings.Provider {
case constants.NAMECHEAP:
ip, err = updateNamecheap(
u.client,
settings.Host,
settings.Domain,
settings.Password,
ip,
)
case constants.GODADDY:
err = updateGoDaddy(
u.client,
settings.Host,
settings.Domain,
settings.Key,
settings.Secret,
ip,
)
case constants.DUCKDNS:
ip, err = updateDuckDNS(
u.client,
settings.Domain,
settings.Token,
ip,
)
case constants.DREAMHOST:
err = updateDreamhost(
u.client,
settings.Domain,
settings.Key,
settings.BuildDomainName(),
ip,
)
case constants.CLOUDFLARE:
err = updateCloudflare(
u.client,
settings.ZoneIdentifier,
settings.Identifier,
settings.Host,
settings.Email,
settings.Key,
settings.UserServiceKey,
settings.Token,
settings.Proxied,
settings.TTL,
ip,
)
case constants.NOIP:
ip, err = updateNoIP(
u.client,
settings.BuildDomainName(),
settings.Username,
settings.Password,
ip,
)
case constants.DNSPOD:
err = updateDNSPod(
u.client,
settings.Domain,
settings.Host,
settings.Token,
ip,
)
case constants.INFOMANIAK:
ip, err = updateInfomaniak(
u.client,
settings.Domain,
settings.Host,
settings.Username,
settings.Password,
ip,
)
case constants.DDNSSDE:
err = updateDDNSS(
u.client,
settings.Domain,
settings.Host,
settings.Username,
settings.Password,
ip,
)
default:
err = fmt.Errorf("provider %q is not supported", settings.Provider)
}
if err != nil {
return constants.FAIL, "", nil, err
}
if ip != nil && ip.Equal(currentIP) {
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
}
return constants.SUCCESS, fmt.Sprintf("changed to %s", ip.String()), ip, nil
}
func (u *updater) incCounter() (value int) {
u.counterMutex.Lock()
defer u.counterMutex.Unlock()
value = u.counter
u.counter++
return value
}
func (u *updater) getPublicIP(ipMethod models.IPMethod, ipVersion models.IPVersion) (ip net.IP, err error) {
var url string
switch {
case ipMethod == constants.PROVIDER:
return nil, nil
case strings.HasPrefix(string(ipMethod), "https://"):
// Custom URL provided
url = string(ipMethod)
case ipMethod == constants.CYCLE:
i := u.incCounter() % len(u.ipMethods)
url = constants.IPMethodMapping()[u.ipMethods[i]]
default:
var ok bool
url, ok = constants.IPMethodMapping()[ipMethod]
if !ok {
return nil, fmt.Errorf("IP method %q not supported", ipMethod)
}
}
return network.GetPublicIP(u.client, url, ipVersion)
}

View File

@@ -1,88 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"testing"
"github.com/golang/mock/gomock"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network/mock_network"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_incCounter(t *testing.T) {
t.Parallel()
const initValue = 100
u := &updater{
counter: initValue,
}
counter := u.incCounter()
assert.Equal(t, initValue, counter)
counter = u.incCounter()
assert.Equal(t, initValue+1, counter)
}
func Test_getPublicIP(t *testing.T) {
t.Parallel()
tests := map[string]struct {
IPMethod models.IPMethod
mockURL string
mockContent []byte
ip net.IP
err error
}{
"bad IP method": {
IPMethod: "abc",
err: fmt.Errorf("IP method \"abc\" not supported"),
},
"provider IP method": {
IPMethod: constants.PROVIDER,
},
"OpenDNS IP method": {
IPMethod: constants.OPENDNS,
mockURL: constants.IPMethodMapping()[constants.OPENDNS],
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
"Custom URL IP method": {
IPMethod: models.IPMethod("https://ipinfo.io/ip"),
mockURL: "https://ipinfo.io/ip",
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
"Cycle IP method": {
IPMethod: constants.CYCLE,
mockURL: constants.IPMethodMapping()[constants.OPENDNS],
mockContent: []byte("blabla 58.67.201.151.25 sds"),
ip: net.IP{58, 67, 201, 151},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
if len(tc.mockURL) != 0 {
client.EXPECT().GetContent(tc.mockURL).Return(tc.mockContent, http.StatusOK, nil).Times(1)
}
u := &updater{
client: client,
ipMethods: []models.IPMethod{constants.OPENDNS, constants.IPINFO},
}
ip, err := u.getPublicIP(tc.IPMethod, constants.IPv4)
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
assert.True(t, tc.ip.Equal(ip))
})
}
}

View File

@@ -52,7 +52,7 @@
<th>Domain</th>
<th>Host</th>
<th>Provider</th>
<th>IP method</th>
<th>IP version</th>
<th>Update status</th>
<th>Set IP</th>
<th>Previous IPs (reverse chronological order)</th>
@@ -62,7 +62,7 @@
<td>{{.Domain}}</td>
<td>{{.Host}}</td>
<td>{{.Provider}}</td>
<td>{{.IPMethod}}</td>
<td>{{.IPVersion}}</td>
<td>{{.Status}}</td>
<td>{{.CurrentIP}}</td>
<td>{{.PreviousIPs}}</td>