14 Commits
v1 ... v2

Author SHA1 Message Date
Quentin McGaw
af68f9ba0f Fix #54 periodic backup to zip files 2020-05-11 23:11:48 +00:00
Thomas Raddatz
f7171e4b01 Updated Cloudflare usage instructions. (#52) 2020-05-11 18:11:59 -04:00
Quentin McGaw
0c028f70e9 Using url package to build urls for APIs (#57) 2020-05-11 18:11:20 -04:00
Quentin McGaw
c194681856 Update dependencies 2020-05-10 17:01:25 +00:00
Quentin McGaw
9c31616b46 Refactored main function 2020-05-10 17:01:09 +00:00
Quentin McGaw
55668d0310 Actualise dockerignore 2020-05-08 00:34:11 +00:00
Quentin McGaw
3bdb8ba5ac Update devcontainer lint settings 2020-05-08 00:34:00 +00:00
Quentin McGaw
345cc754ff Update golibs 2020-05-08 00:33:51 +00:00
Quentin McGaw
9e05c6164d Update golang to 1.14 2020-05-08 00:30:49 +00:00
Quentin McGaw
ea79ca53ea Update Golangci-lint to 1.26.0 2020-05-08 00:30:42 +00:00
Quentin McGaw
6a3c280f30 Buildx readme badge 2020-04-05 02:36:42 +00:00
Quentin McGaw
01e982a4cd Golangci-lint buildx fix
- Timeout of 10 minutes
- Run golangci-lint after tests and build
- Removed arch armv6 and ppc64le (too slow for golangci-lint)
2020-04-05 01:27:35 +00:00
Quentin McGaw
99d33bbcf9 Golangci lint and fixing lint issues (#48) 2020-04-04 16:38:10 -04:00
Quentin McGaw
e38351e5a4 Remove sqlite (#46)
- Removed support for SQLite based database
- Removed migration from sqlite to json file persistence storage
- Updated announcement
- Scratch based Docker image
- Much faster rebuilds
- ARM v6 and ppc64le CPU architectures added
2020-04-04 14:11:59 -04:00
47 changed files with 1522 additions and 1348 deletions

View File

@@ -43,10 +43,58 @@
"deepCompletion": true,
"usePlaceholders": false
},
"go.lintTool": "golangci-lint",
"go.lintFlags": [
"--fast",
"--enable",
"rowserrcheck",
"--enable",
"bodyclose",
"--enable",
"dogsled",
"--enable",
"dupl",
"--enable",
"gochecknoglobals",
"--enable",
"gochecknoinits",
"--enable",
"gocognit",
"--enable",
"goconst",
"--enable",
"gocritic",
"--enable",
"gocyclo",
"--enable",
"goimports",
"--enable",
"golint",
"--enable",
"gosec",
"--enable",
"interfacer",
"--enable",
"maligned",
"--enable",
"misspell",
"--enable",
"nakedret",
"--enable",
"prealloc",
"--enable",
"scopelint",
"--enable",
"unconvert",
"--enable",
"unparam",
"--enable",
"whitespace"
],
// Golang on save
"go.buildOnSave": "package",
"go.lintOnSave": "package",
"go.vetOnSave": "package",
"go.buildOnSave": "workspace",
"go.lintOnSave": "workspace",
"go.vetOnSave": "workspace",
"editor.formatOnSave": true,
"[go]": {
"editor.codeActionsOnSave": {

View File

@@ -1,11 +1,10 @@
.devcontainer
.git
*.exe
.github
.vscode
.travis.yml
docker-compose.yml
LICENSE
*.md
readme
.gitignore
.devcontainer
.vscode
config.json
docker-compose.yml
LICENSE
README.md

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
*.exe
updater
.vscode
.vscode

49
.golangci.yml Normal file
View File

@@ -0,0 +1,49 @@
linters-settings:
maligned:
suggest-new: true
misspell:
locale: US
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- dogsled
- dupl
- errcheck
- gochecknoglobals
- gochecknoinits
- gocognit
- goconst
- gocritic
- gocyclo
- goimports
- golint
- gosec
- gosimple
- govet
- ineffassign
- interfacer
- maligned
- misspell
- nakedret
- prealloc
- rowserrcheck
- scopelint
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- whitespace
run:
skip-dirs:
- .devcontainer
- .github
service:
golangci-lint-version: 1.26.x # use the fixed version to not introduce new linters unexpectedly

View File

@@ -1,17 +1,25 @@
ARG ALPINE_VERSION=3.11
ARG GO_VERSION=1.13
ARG GO_VERSION=1.14
FROM alpine:${ALPINE_VERSION} AS alpine
RUN apk --update add ca-certificates tzdata
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
RUN apk --update add git g++
ARG GOLANGCI_LINT_VERSION=v1.26.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}
WORKDIR /tmp/gobuild
COPY .golangci.yml .
COPY go.mod go.sum ./
RUN go mod download 2>&1
COPY internal/ ./internal/
COPY cmd/updater/main.go .
RUN CGO_ENABLED=0 go test ./...
RUN CGO_ENABLED=1 go build -a -installsuffix cgo -ldflags="-s -w" -o app
RUN go test ./...
RUN go build -ldflags="-s -w" -o app
RUN golangci-lint run --timeout=10m
FROM alpine:${ALPINE_VERSION}
FROM scratch
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
@@ -25,13 +33,8 @@ LABEL \
org.opencontainers.image.source="https://github.com/qdm12/ddns-updater" \
org.opencontainers.image.title="ddns-updater" \
org.opencontainers.image.description="Universal DNS updater with WebUI. Works with Namecheap, Cloudflare, GoDaddy, DuckDns, Dreamhost, DNSPod and NoIP"
RUN apk add --update sqlite ca-certificates && \
mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 && \
rm -rf /var/cache/apk/* && \
# Creating empty database file in case nothing is mounted
mkdir -p /updater/data && \
chown -R 1000 /updater && \
chmod 700 /updater/data
COPY --from=alpine --chown=1000 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=alpine --chown=1000 /usr/share/zoneinfo /usr/share/zoneinfo
EXPOSE 8000
HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
USER 1000
@@ -44,6 +47,8 @@ ENV DELAY=10m \
NODE_ID=0 \
HTTP_TIMEOUT=10s \
GOTIFY_URL= \
GOTIFY_TOKEN=
GOTIFY_TOKEN= \
BACKUP_PERIOD=0 \
BACKUP_DIRECTORY=/updater/data
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
COPY --chown=1000 ui/* /updater/ui/

View File

@@ -2,11 +2,9 @@
*Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod, Infomaniak, ddnss.de and DuckDNS*
**SQLite migration support will be removed on 1 April 2020, so be sure to update your image before that**
[![DDNS Updater by Quentin McGaw](https://github.com/qdm12/ddns-updater/raw/master/readme/title.png)](https://hub.docker.com/r/qmcgaw/ddns-updater)
[![Build Status](https://travis-ci.org/qdm12/ddns-updater.svg?branch=master)](https://travis-ci.org/qdm12/ddns-updater)
[![Build status](https://github.com/qdm12/ddns-updater/workflows/Buildx%20latest/badge.svg)](https://github.com/qdm12/ddns-updater/actions?query=workflow%3A%22Buildx+latest%22)
[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater)
[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater)
[![Image size](https://images.microbadger.com/badges/image/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater)
@@ -24,12 +22,12 @@
![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png)
- Lightweight based on a Go binary and *Alpine 3.11* with Sqlite and Ca-Certificates packages
- 12.3MB 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
- Sends notifications to your Android phone, see the [**Gotify**](#Gotify) section (it's free, open source and self hosted 🆒)
- Compatible with `amd64`, `386`, `arm64` and `arm32v7` (Raspberry Pis) CPU architectures.
- Compatible with `amd64`, `386`, `arm64`, `arm32v7` (Raspberry Pis) CPU architectures.
## Setup
@@ -94,6 +92,8 @@
docker-compose up -d
```
1. You can update the image with `docker pull qmcgaw/ddns-updater`. Other [Docker image tags are available](https://hub.docker.com/repository/docker/qmcgaw/ddns-updater/tags).
## Configuration
Start by having the following content in *config.json*:
@@ -148,12 +148,12 @@ Namecheap:
Cloudflare:
- `"zone_identifier"`
- `"identifier"`
- `"zone_identifier"` is the Zone ID of your site
- `"identifier"` is the DNS record identifier as returned by the Cloudflare "List DNS Records" API (see below)
- `"host"` is your host and can be a subdomain, `"@"` or `"*"` generally
- `"ttl"` integer value for record TTL in seconds (specify 1 for automatic)
- One of the following:
- Email `"email"` and key `"key"`
- Email `"email"` and Global API Key `"key"`
- User service key `"user_service_key"`
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone.
- *Optionally*, `"proxied"` can be `true` or `false` to use the proxy services of Cloudflare
@@ -210,6 +210,8 @@ DDNSS.de:
| `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`.
### Host firewall
@@ -376,6 +378,8 @@ 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

View File

@@ -1,151 +1,274 @@
package main
import (
"context"
"net"
_ "github.com/mattn/go-sqlite3"
"fmt"
"net/http"
"os"
"time"
"github.com/qdm12/golibs/admin"
"github.com/qdm12/golibs/files"
libhealthcheck "github.com/qdm12/golibs/healthcheck"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/server"
"github.com/qdm12/golibs/signals"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/ddns-updater/internal/env"
"github.com/qdm12/ddns-updater/internal/handlers"
"github.com/qdm12/ddns-updater/internal/healthcheck"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/params"
"github.com/qdm12/ddns-updater/internal/persistence"
"github.com/qdm12/ddns-updater/internal/splash"
"github.com/qdm12/ddns-updater/internal/trigger"
"github.com/qdm12/ddns-updater/internal/update"
)
func main() {
logger, err := logging.NewLogger(logging.ConsoleEncoding, logging.InfoLevel, -1)
if err != nil {
panic(err)
}
paramsReader := params.NewParamsReader(logger)
encoding, level, nodeID, err := paramsReader.GetLoggerConfig()
if err != nil {
logger.Error(err)
} else {
logger, err = logging.NewLogger(encoding, level, nodeID)
}
if libhealthcheck.Mode(os.Args) {
// Running the program in a separate instance through the Docker
// built-in healthcheck, in an ephemeral fashion to query the
// long running instance of the program about its status
if err := libhealthcheck.Query(); err != nil {
logger.Error(err)
os.Exit(1)
}
os.Exit(0)
}
fmt.Println(splash.Splash(paramsReader))
e := env.NewEnv(logger)
gotifyURL, err := paramsReader.GetGotifyURL()
e.FatalOnError(err)
if gotifyURL != nil {
gotifyToken, err := paramsReader.GetGotifyToken()
e.FatalOnError(err)
e.SetGotify(admin.NewGotify(*gotifyURL, gotifyToken, &http.Client{Timeout: time.Second}))
}
listeningPort, warning, err := paramsReader.GetListeningPort()
e.FatalOnError(err)
if len(warning) > 0 {
logger.Warn(warning)
}
rootURL, err := paramsReader.GetRootURL()
e.FatalOnError(err)
defaultPeriod, err := paramsReader.GetDelay(libparams.Default("10m"))
e.FatalOnError(err)
dir, err := paramsReader.GetExeDir()
e.FatalOnError(err)
dataDir, err := paramsReader.GetDataDir(dir)
e.FatalOnError(err)
fileManager := files.NewFileManager()
dbSQLiteExists, err := fileManager.FileExists(dataDir + "/updates.db")
e.FatalOnError(err)
dbJSONExists, err := fileManager.FileExists(dataDir + "/updates.json")
e.FatalOnError(err)
var persistentDB persistence.Database
if dbSQLiteExists && !dbJSONExists {
logger.Warn("Migrating from SQLite to JSON based database file")
sqlite, err := persistence.NewSQLite(dataDir)
e.FatalOnError(err)
persistentDB, err = persistence.NewJSON(dataDir)
e.FatalOnError(err)
err = persistence.Migrate(sqlite, persistentDB, logger)
e.FatalOnError(err)
logger.Info("Success; you can now safely delete %s", dataDir+"/updates.db")
} else {
persistentDB, err = persistence.NewJSON(dataDir)
e.FatalOnError(err)
}
go signals.WaitForExit(e.ShutdownFromSignal)
settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
for _, w := range warnings {
e.Warn(w)
}
if err != nil {
e.Fatal(err)
}
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")
}
for _, err := range network.NewConnectivity(5 * time.Second).Checks("google.com") {
e.Warn(err)
}
var records []models.Record
idToPeriod := make(map[int]time.Duration)
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)
if err != nil {
e.FatalOnError(err)
}
records = append(records, models.NewRecord(setting, events))
idToPeriod[id] = defaultPeriod
if setting.Delay > 0 {
idToPeriod[id] = setting.Delay
}
}
HTTPTimeout, err := paramsReader.GetHTTPTimeout()
e.FatalOnError(err)
client := network.NewClient(HTTPTimeout)
db := data.NewDatabase(records, persistentDB)
e.SetDB(db)
updater := update.NewUpdater(db, logger, client, e.Notify)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forceUpdate := trigger.StartUpdates(ctx, updater, idToPeriod, e.CheckError)
forceUpdate()
productionHandlerFunc := handlers.NewHandler(rootURL, dir, db, logger, forceUpdate, e.CheckError).GetHandlerFunc()
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)
e.Notify(1, fmt.Sprintf("Just launched\nIt has %d records to watch", len(records)))
serverErrs := server.RunServers(
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionHandlerFunc},
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
)
if len(serverErrs) > 0 {
e.Fatal(serverErrs)
}
}
package main
import (
"context"
"net"
"os/signal"
"syscall"
"fmt"
"net/http"
"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"
"github.com/qdm12/ddns-updater/internal/healthcheck"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/params"
"github.com/qdm12/ddns-updater/internal/persistence"
"github.com/qdm12/ddns-updater/internal/splash"
"github.com/qdm12/ddns-updater/internal/trigger"
"github.com/qdm12/ddns-updater/internal/update"
)
func main() {
os.Exit(_main(context.Background(), time.Now))
// returns 1 on error
// returns 2 on os signal
}
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
// built-in healthcheck, in an ephemeral fashion to query the
// long running instance of the program about its status
if err := libhealthcheck.Query(); err != nil {
fmt.Println(err)
return 1
}
return 0
}
logger, err := setupLogger()
if err != nil {
fmt.Println(err)
return 1
}
paramsReader := params.NewReader(logger)
fmt.Println(splash.Splash(
paramsReader.GetVersion(),
paramsReader.GetVcsRef(),
paramsReader.GetBuildDate()))
notify, err := setupGotify(paramsReader, logger)
if err != nil {
logger.Error(err)
return 1
}
dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, err := getParams(paramsReader)
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
persistentDB, err := persistence.NewJSON(dataDir)
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
for _, w := range warnings {
logger.Warn(w)
notify(2, w)
}
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
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")
}
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)
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++
}
HTTPTimeout, err := paramsReader.GetHTTPTimeout()
if err != nil {
logger.Error(err)
notify(4, err)
return 1
}
client := network.NewClient(HTTPTimeout)
defer client.Close()
db := data.NewDatabase(records, persistentDB)
defer func() {
if err := db.Close(); err != nil {
logger.Error(err)
}
}()
updater := update.NewUpdater(db, logger, client, notify)
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()
productionHandlerFunc := handlers.NewHandler(rootURL, dir, db, logger, forceUpdate, checkError).GetHandlerFunc()
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)
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: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
)
}()
go backupRunLoop(ctx, backupPeriod, dir, backupDirectory, logger, timeNow)
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals,
syscall.SIGINT,
syscall.SIGTERM,
os.Interrupt,
)
select {
case errors := <-serverErrors:
for _, err := range errors {
logger.Error(err)
}
return 1
case signal := <-osSignals:
message := fmt.Sprintf("Stopping program: caught OS signal %q", signal)
logger.Warn(message)
notify(2, message)
return 2
case <-ctx.Done():
message := fmt.Sprintf("Stopping program: %s", ctx.Err())
logger.Warn(message)
return 1
}
}
func setupLogger() (logging.Logger, error) {
paramsReader := params.NewReader(nil)
encoding, level, nodeID, err := paramsReader.GetLoggerConfig()
if err != nil {
return nil, err
}
return logging.NewLogger(encoding, level, nodeID)
}
func setupGotify(paramsReader params.Reader, logger logging.Logger) (notify func(priority int, messageArgs ...interface{}), err error) {
gotifyURL, err := paramsReader.GetGotifyURL()
if err != nil {
return nil, err
} else if gotifyURL == nil {
return func(priority int, messageArgs ...interface{}) {}, nil
}
gotifyToken, err := paramsReader.GetGotifyToken()
if err != nil {
return nil, err
}
gotify := admin.NewGotify(*gotifyURL, gotifyToken, &http.Client{Timeout: time.Second})
return func(priority int, messageArgs ...interface{}) {
if err := gotify.Notify("DDNS Updater", priority, messageArgs...); err != nil {
logger.Error(err)
}
}, 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
}
dataDir, err = paramsReader.GetDataDir(dir)
if err != nil {
return "", "", "", "", 0, 0, "", err
}
listeningPort, _, err = paramsReader.GetListeningPort()
if err != nil {
return "", "", "", "", 0, 0, "", err
}
rootURL, err = paramsReader.GetRootURL()
if err != nil {
return "", "", "", "", 0, 0, "", err
}
defaultPeriod, err = paramsReader.GetDelay(libparams.Default("10m"))
if err != nil {
return "", "", "", "", 0, 0, "", err
}
backupPeriod, err = paramsReader.GetBackupPeriod()
if err != nil {
return "", "", "", "", 0, 0, "", err
}
backupDirectory, err = paramsReader.GetBackupDirectory()
if err != nil {
return "", "", "", "", 0, 0, "", err
}
return dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, nil
}
func backupRunLoop(ctx context.Context, backupPeriod time.Duration, exeDir, outputDir string,
logger logging.Logger, timeNow func() time.Time) {
logger = logger.WithPrefix("backup: ")
if backupPeriod == 0 {
logger.Info("disabled")
return
}
logger.Info("each %s; writing zip files to directory %s", backupPeriod, outputDir)
ziper := backup.NewZiper()
timer := time.NewTimer(backupPeriod)
for {
filepath := fmt.Sprintf("%s/ddns-updater-backup-%d.zip", outputDir, timeNow().UnixNano())
if err := ziper.ZipFiles(
filepath,
fmt.Sprintf("%s/data/updates.json", exeDir),
fmt.Sprintf("%s/data/config.json", exeDir)); err != nil {
logger.Error(err)
}
select {
case <-timer.C:
timer.Reset(backupPeriod)
case <-ctx.Done():
timer.Stop()
return
}
}
}

View File

@@ -18,4 +18,6 @@ services:
- HTTP_TIMEOUT=10s
- GOTIFY_URL=
- GOTIFY_TOKEN=
- BACKUP_PERIOD=0
- BACKUP_DIRECTORY=/updater/data
restart: always

8
go.mod
View File

@@ -3,9 +3,9 @@ module github.com/qdm12/ddns-updater
go 1.13
require (
github.com/golang/mock v1.4.3
github.com/google/uuid v1.1.1
github.com/kyokomi/emoji v2.1.0+incompatible
github.com/mattn/go-sqlite3 v1.10.0
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052
github.com/stretchr/testify v1.4.0
github.com/kyokomi/emoji v2.2.2+incompatible
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151
github.com/stretchr/testify v1.5.1
)

26
go.sum
View File

@@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb h1:D4uzjWwKYQ5XnAvUbuvHW93esHg7F8N/OYeBBcJoTr0=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI=
@@ -35,6 +37,8 @@ github.com/go-openapi/swag v0.17.0 h1:iqrgMg7Q7SvtbWLlltPrkMs0UBJI6oTSs79JFRUi88
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/validate v0.17.0 h1:pqoViQz3YLOGIhAmD0N4Lt6pa/3Gnj3ymKqQwq8iS6U=
github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
@@ -51,10 +55,15 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o=
github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/kyokomi/emoji v2.2.2+incompatible h1:gaQFbK2+uSxOR4iGZprJAbpmtqTrHhSdgOyIMD6Oidc=
github.com/kyokomi/emoji v2.2.2+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc=
@@ -67,8 +76,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052 h1:6KVUzd4oLyHcmmYsXdpE3HR9FUSx3FF8UBTRlJPCKLc=
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052/go.mod h1:YULaFjj6VGmhjak6f35sUWwEleHUmngN5IQ3kdvd6XE=
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/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=
@@ -77,6 +86,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc=
@@ -100,10 +111,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs=
@@ -119,3 +135,5 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

67
internal/backup/zip.go Normal file
View File

@@ -0,0 +1,67 @@
package backup
import (
"archive/zip"
"io"
"os"
)
type Ziper interface {
ZipFiles(outputFilepath string, inputFilepaths ...string) error
}
type ziper struct {
createFile func(name string) (*os.File, error)
openFile func(name string) (*os.File, error)
ioCopy func(dst io.Writer, src io.Reader) (written int64, err error)
}
func NewZiper() Ziper {
return &ziper{
createFile: os.Create,
openFile: os.Open,
ioCopy: io.Copy,
}
}
func (z *ziper) ZipFiles(outputFilepath string, inputFilepaths ...string) error {
f, err := z.createFile(outputFilepath)
if err != nil {
return err
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, filepath := range inputFilepaths {
if err := z.addFile(w, filepath); err != nil {
return err
}
}
return nil
}
func (z *ziper) addFile(w *zip.Writer, filepath string) error {
f, err := z.openFile(filepath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
// Using FileInfoHeader() above only uses the basename of the file. If we want
// to preserve the folder structure we can overwrite this with the full path.
// header.Name = filepath
header.Method = zip.Deflate
ioWriter, err := w.CreateHeader(header)
if err != nil {
return err
}
_, err = z.ioCopy(ioWriter, f)
return err
}

View File

@@ -3,34 +3,34 @@ package constants
import "github.com/qdm12/ddns-updater/internal/models"
const (
HTML_FAIL models.HTML = `<font color="red"><b>Failure</b></font>`
HTML_SUCCESS models.HTML = `<font color="green"><b>Success</b></font>`
HTML_UPTODATE models.HTML = `<font color="#00CC66"><b>Up to date</b></font>`
HTML_UPDATING models.HTML = `<font color="orange"><b>Updating</b></font>`
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
HTML_NAMECHEAP models.HTML = "<a href=\"https://namecheap.com\">Namecheap</a>"
HTML_GODADDY models.HTML = "<a href=\"https://godaddy.com\">GoDaddy</a>"
HTML_DUCKDNS models.HTML = "<a href=\"https://duckdns.org\">DuckDNS</a>"
HTML_DREAMHOST models.HTML = "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>"
HTML_CLOUDFLARE models.HTML = "<a href=\"https://www.cloudflare.com\">Cloudflare</a>"
HTML_NOIP models.HTML = "<a href=\"https://www.noip.com/\">NoIP</a>"
HTML_DNSPOD models.HTML = "<a href=\"https://www.dnspod.cn/\">DNSPod</a>"
HTML_INFOMANIAK models.HTML = "<a href=\"https://www.infomaniak.com/\">Infomaniak</a>"
HTML_DDNSSDE models.HTML = "<a href=\"https://ddnss.de/\">DDNSS.de</a>"
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 (
HTML_GOOGLE models.HTML = "<a href=\"https://google.com/search?q=ip\">Google</a>"
HTML_OPENDNS models.HTML = "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
HTML_IFCONFIG models.HTML = "<a href=\"https://ifconfig.io\">ifconfig.io</a>"
HTML_IPINFO models.HTML = "<a href=\"https://ipinfo.io\">ipinfo.io</a>"
HTML_IPIFY models.HTML = "<a href=\"https://api.ipify.org\">api.ipify.org</a>"
HTML_IPIFY6 models.HTML = "<a href=\"https://api6.ipify.org\">api6.ipify.org</a>"
HTML_DDNSS models.HTML = "<a href=\"https://ddnss.de/meineip.php\">ddnss.de</a>"
HTML_DDNSS4 models.HTML = "<a href=\"https://ip4.ddnss.de/meineip.php\">ip4.ddnss.de</a>"
HTML_DDNSS6 models.HTML = "<a href=\"https://ip6.ddnss.de/meineip.php\">ip6.ddns.de</a>"
HTML_CYCLE models.HTML = "Cycling"
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

@@ -4,13 +4,13 @@ import "regexp"
const (
goDaddyKey = `[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}`
godaddySecret = `[A-Za-z0-9]{22}`
RegexDuckDNSToken = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}`
namecheapPassword = `[a-f0-9]{32}`
godaddySecret = `[A-Za-z0-9]{22}` // #nosec
duckDNSToken = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}` // #nosec
namecheapPassword = `[a-f0-9]{32}` // #nosec
dreamhostKey = `[a-zA-Z0-9]{16}`
cloudflareKey = `[a-zA-Z0-9]+`
cloudflareUserServiceKey = `v1\.0.+`
cloudflareToken = `[a-zA-Z0-9_]{40}`
cloudflareToken = `[a-zA-Z0-9_]{40}` // #nosec
)
func MatchGodaddyKey(s string) bool {
@@ -22,7 +22,7 @@ func MatchGodaddySecret(s string) bool {
}
func MatchDuckDNSToken(s string) bool {
return regexp.MustCompile("^" + RegexDuckDNSToken + "$").MatchString(s)
return regexp.MustCompile("^" + duckDNSToken + "$").MatchString(s)
}
func MatchNamecheapPassword(s string) bool {

View File

@@ -1,10 +1,10 @@
package constants
const (
// Annoucement is a message annoucement
Annoucement = "Added support for DDNSS.de"
// AnnoucementExpiration is the expiration date of the annoucement in format yyyy-mm-dd
AnnoucementExpiration = "2020-03-20"
// Announcement is a message announcement
Announcement = "Smaller Docker image based on Scratch (12.3MB)"
// AnnouncementExpiration is the expiration date of the announcement in format yyyy-mm-dd
AnnouncementExpiration = "2020-04-20"
)
const (

View File

@@ -7,11 +7,11 @@ import (
"github.com/stretchr/testify/assert"
)
func Test_AnnoucementExpiration(t *testing.T) {
func Test_AnnouncementExpiration(t *testing.T) {
t.Parallel()
if len(AnnoucementExpiration) == 0 {
if len(AnnouncementExpiration) == 0 {
return
}
_, err := time.Parse("2006-01-02", AnnoucementExpiration)
_, err := time.Parse("2006-01-02", AnnouncementExpiration)
assert.NoError(t, err)
}

View File

@@ -1,10 +0,0 @@
package constants
const (
NamecheapURL = "https://dynamicdns.park-your-domain.com/update"
GoDaddyURL = "https://api.godaddy.com/v1/domains"
DuckDNSURL = "https://www.duckdns.org/update"
DreamhostURL = "https://api.dreamhost.com"
CloudflareURL = "https://api.cloudflare.com/client/v4"
NoIPURL = "https://dynupdate.no-ip.com/nic/update"
)

132
internal/env/env.go vendored
View File

@@ -1,132 +0,0 @@
package env
import (
"os"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/golibs/admin"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
)
type Env interface {
SetClient(client network.Client)
SetGotify(gotify admin.Gotify)
SetDB(db data.Database)
Notify(priority int, messageArgs ...interface{})
Info(messageArgs ...interface{})
Warn(messageArgs ...interface{})
CheckError(err error)
FatalOnError(err error)
ShutdownFromSignal(signal string) (exitCode int)
Fatal(messageArgs ...interface{})
Shutdown() (exitCode int)
}
func NewEnv(logger logging.Logger) Env {
return &env{logger: logger}
}
// env contains objects necessary to the main function.
// These are created at start and are needed to the top-level
// working management of the program.
type env struct {
logger logging.Logger
client network.Client
gotify admin.Gotify
db data.Database
}
func (e *env) SetClient(client network.Client) {
e.client = client
}
func (e *env) SetGotify(gotify admin.Gotify) {
e.gotify = gotify
}
func (e *env) SetDB(db data.Database) {
e.db = db
}
// Notify sends a notification to the Gotify server.
func (e *env) Notify(priority int, messageArgs ...interface{}) {
if e.gotify == nil {
return
}
if err := e.gotify.Notify("DDNS Updater", priority, messageArgs...); err != nil {
e.logger.Error(err)
}
}
// Info logs a message and sends a notification to the Gotify server.
func (e *env) Info(messageArgs ...interface{}) {
e.logger.Info(messageArgs...)
e.Notify(1, messageArgs...)
}
// Warn logs a message and sends a notification to the Gotify server.
func (e *env) Warn(messageArgs ...interface{}) {
e.logger.Warn(messageArgs...)
e.Notify(2, messageArgs...)
}
// CheckError logs an error and sends a notification to the Gotify server
// if the error is not nil.
func (e *env) CheckError(err error) {
if err == nil {
return
}
s := err.Error()
e.logger.Error(s)
if len(s) > 100 {
s = s[:100] + "..." // trim down message for notification
}
e.Notify(3, s)
}
// FatalOnError calls Fatal if the error is not nil.
func (e *env) FatalOnError(err error) {
if err != nil {
e.Fatal(err)
}
}
// Shutdown cleanly exits the program by closing all connections,
// databases and syncing the loggers.
func (e *env) Shutdown() (exitCode int) {
defer func() {
if err := e.logger.Sync(); err != nil {
exitCode = 99
}
}()
if e.client != nil {
e.client.Close()
}
if e.db != nil {
if err := e.db.Close(); err != nil {
e.logger.Error(err)
return 1
}
}
return 0
}
// ShutdownFromSignal logs a warning, sends a notification to Gotify and shutdowns
// the program cleanly when a OS level signal is received. It should be passed as a
// callback to a function which would catch such signal.
func (e *env) ShutdownFromSignal(signal string) (exitCode int) {
e.logger.Warn("Program stopped with signal %q", signal)
e.Notify(1, "Caught OS signal %q", signal)
return e.Shutdown()
}
// Fatal logs an error, sends a notification to Gotify and shutdowns the program.
// It exits the program with an exit code of 1.
func (e *env) Fatal(messageArgs ...interface{}) {
e.logger.Error(messageArgs...)
e.Notify(4, messageArgs...)
_ = e.Shutdown()
os.Exit(1)
}

View File

@@ -19,7 +19,7 @@ type Handler interface {
type handler struct {
rootURL string
UIDir string
uiDir string
db data.Database
logger logging.Logger
forceUpdate func()
@@ -28,11 +28,11 @@ type handler struct {
}
// NewHandler returns a Handler object
func NewHandler(rootURL, UIDir string, db data.Database, logger logging.Logger,
func NewHandler(rootURL, uiDir string, db data.Database, logger logging.Logger,
forceUpdate func(), onError func(err error)) Handler {
return &handler{
rootURL: rootURL,
UIDir: UIDir,
uiDir: uiDir,
db: db,
logger: logger,
forceUpdate: forceUpdate,
@@ -48,7 +48,7 @@ func (h *handler) GetHandlerFunc() http.HandlerFunc {
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"))
t := template.Must(template.ParseFiles(h.uiDir + "/ui/index.html"))
var htmlData models.HTMLData
for _, record := range h.db.SelectAll() {
row := html.ConvertRecord(record, h.getTime())

View File

@@ -10,6 +10,7 @@ import (
)
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),
@@ -24,7 +25,7 @@ func ConvertRecord(record models.Record, now time.Time) models.HTMLRow {
message = fmt.Sprintf("(%s)", message)
}
if len(record.Status) == 0 {
row.Status = "N/A"
row.Status = NotAvailable
} else {
row.Status = models.HTML(fmt.Sprintf("%s %s, %s",
convertStatus(record.Status),
@@ -35,10 +36,10 @@ func ConvertRecord(record models.Record, now time.Time) models.HTMLRow {
if currentIP != nil {
row.CurrentIP = models.HTML(`<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>")
} else {
row.CurrentIP = "N/A"
row.CurrentIP = NotAvailable
}
previousIPs := record.History.GetPreviousIPs()
row.PreviousIPs = "N/A"
row.PreviousIPs = NotAvailable
if len(previousIPs) > 0 {
var previousIPsStr []string
const maxPreviousIPs = 2
@@ -57,13 +58,13 @@ func ConvertRecord(record models.Record, now time.Time) models.HTMLRow {
func convertStatus(status models.Status) models.HTML {
switch status {
case constants.SUCCESS:
return constants.HTML_SUCCESS
return constants.HTMLSuccess
case constants.FAIL:
return constants.HTML_FAIL
return constants.HTMLFail
case constants.UPTODATE:
return constants.HTML_UPTODATE
return constants.HTMLUpdate
case constants.UPDATING:
return constants.HTML_UPDATING
return constants.HTMLUpdating
default:
return "Unknown status"
}
@@ -72,23 +73,23 @@ func convertStatus(status models.Status) models.HTML {
func convertProvider(provider models.Provider) models.HTML {
switch provider {
case constants.NAMECHEAP:
return constants.HTML_NAMECHEAP
return constants.HTMLNamecheap
case constants.GODADDY:
return constants.HTML_GODADDY
return constants.HTMLGodaddy
case constants.DUCKDNS:
return constants.HTML_DUCKDNS
return constants.HTMLDuckDNS
case constants.DREAMHOST:
return constants.HTML_DREAMHOST
return constants.HTMLDreamhost
case constants.CLOUDFLARE:
return constants.HTML_CLOUDFLARE
return constants.HTMLCloudflare
case constants.NOIP:
return constants.HTML_NOIP
return constants.HTMLNoIP
case constants.DNSPOD:
return constants.HTML_DNSPOD
return constants.HTMLDNSPod
case constants.INFOMANIAK:
return constants.HTML_INFOMANIAK
return constants.HTMLInfomaniak
case constants.DDNSSDE:
return constants.HTML_DDNSSDE
return constants.HTMLDdnssde
default:
s := string(provider)
if strings.HasPrefix("https://", s) {
@@ -100,31 +101,31 @@ func convertProvider(provider models.Provider) models.HTML {
}
}
func convertIPMethod(IPMethod models.IPMethod, provider models.Provider) models.HTML {
func convertIPMethod(ipMethod models.IPMethod, provider models.Provider) models.HTML {
// TODO map to icons
switch IPMethod {
switch ipMethod {
case constants.PROVIDER:
return convertProvider(provider)
case constants.OPENDNS:
return constants.HTML_OPENDNS
return constants.HTMLOpenDNS
case constants.IFCONFIG:
return constants.HTML_IFCONFIG
return constants.HTMLIfconfig
case constants.IPINFO:
return constants.HTML_IPINFO
return constants.HTMLIpinfo
case constants.IPIFY:
return constants.HTML_IPIFY
return constants.HTMLIpify
case constants.IPIFY6:
return constants.HTML_IPIFY6
return constants.HTMLIpify6
case constants.DDNSS:
return constants.HTML_DDNSS
return constants.HTMLDdnss
case constants.DDNSS4:
return constants.HTML_DDNSS4
return constants.HTMLDdnss4
case constants.DDNSS6:
return constants.HTML_DDNSS6
return constants.HTMLDdnss6
case constants.CYCLE:
return constants.HTML_CYCLE
return constants.HTMLCycle
default:
return models.HTML(string(IPMethod))
return models.HTML(string(ipMethod))
}
}

View File

@@ -1,14 +1,14 @@
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
HTML string
// IPVersion is ipv4 or ipv6
IPVersion string
)
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
HTML string
// IPVersion is ipv4 or ipv6
IPVersion string
)

View File

@@ -1,19 +1,19 @@
package models
// HTMLData is a list of HTML fields to be rendered.
// It is exported so that the HTML template engine can render it.
type HTMLData struct {
Rows []HTMLRow
}
// HTMLRow contains HTML fields to be rendered
// It is exported so that the HTML template engine can render it.
type HTMLRow struct {
Domain HTML
Host HTML
Provider HTML
IPMethod HTML
Status HTML
CurrentIP HTML
PreviousIPs HTML
}
package models
// HTMLData is a list of HTML fields to be rendered.
// It is exported so that the HTML template engine can render it.
type HTMLData struct {
Rows []HTMLRow
}
// HTMLRow contains HTML fields to be rendered
// It is exported so that the HTML template engine can render it.
type HTMLRow struct {
Domain HTML
Host HTML
Provider HTML
IPMethod HTML
Status HTML
CurrentIP HTML
PreviousIPs HTML
}

View File

@@ -1,55 +1,57 @@
package models
import (
"encoding/json"
"time"
)
// Settings contains the elements to update the DNS record
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 {
if settings.Host == "@" {
return settings.Domain
} else if settings.Host == "*" {
return "any." + settings.Domain
} else {
return settings.Host + "." + settings.Domain
}
}
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

@@ -13,27 +13,27 @@ import (
)
// GetPublicIP downloads a webpage and extracts the IP address from it
func GetPublicIP(client network.Client, URL string, IPVersion models.IPVersion) (ip net.IP, err error) {
content, status, err := client.GetContent(URL)
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 from %s: %s", ipVersion, url, err)
} else if status != http.StatusOK {
return nil, fmt.Errorf("cannot get public %s address from %s: HTTP status code %d", IPVersion, URL, status)
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 {
if ipVersion == constants.IPv6 {
regexSearch = verifier.SearchIPv6
}
ips := regexSearch(string(content))
if ips == nil {
return nil, fmt.Errorf("no public %s address found at %s", IPVersion, URL)
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, " "))
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 nil, fmt.Errorf("Public IP address %q found at %s is not valid", ips[0], url)
}
return ip, nil
}

View File

@@ -6,9 +6,10 @@ import (
"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/mocks"
"github.com/qdm12/golibs/network/mock_network"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -63,8 +64,10 @@ func Test_GetPublicIP(t *testing.T) {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
client.On("GetContent", URL).Return(tc.mockContent, tc.mockStatus, tc.mockErr).Once()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
client.EXPECT().GetContent(URL).Return(tc.mockContent, tc.mockStatus, tc.mockErr).Times(1)
ip, err := GetPublicIP(client, URL, tc.IPVersion)
if tc.err != nil {
require.Error(t, err)
@@ -72,9 +75,7 @@ func Test_GetPublicIP(t *testing.T) {
} else {
assert.NoError(t, err)
}
fmt.Printf("%#v\n", ip)
assert.True(t, tc.ip.Equal(ip))
client.AssertExpectations(t)
})
}
}

View File

@@ -7,12 +7,12 @@ import (
)
// BuildHTTPPut is used for GoDaddy and Cloudflare only
func BuildHTTPPut(URL string, body interface{}) (request *http.Request, err error) {
func BuildHTTPPut(url string, body interface{}) (request *http.Request, err error) {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
request, err = http.NewRequest(http.MethodPut, URL, bytes.NewBuffer(b))
request, err = http.NewRequest(http.MethodPut, url, bytes.NewBuffer(b))
if err != nil {
return nil, err
}

View File

@@ -8,131 +8,210 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
)
func (p *params) isConsistent(settings models.Settings) error {
// General validity checks
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 !p.verifier.MatchDomain(settings.Domain):
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
}
}
// Checks for each IP versions
switch settings.IPVersion {
func settingsIPVersionChecks(ipVersion models.IPVersion, ipMethod models.IPMethod, provider models.Provider) error {
switch ipVersion {
case constants.IPv4:
switch settings.IPMethod {
switch ipMethod {
case constants.IPIFY6, constants.DDNSS6:
return fmt.Errorf("IP method %s is only for IPv6 addresses", settings.IPMethod)
return fmt.Errorf("IP method %s is only for IPv6 addresses", ipMethod)
}
case constants.IPv6:
switch settings.IPMethod {
switch ipMethod {
case constants.IPIFY, constants.DDNSS4:
return fmt.Errorf("IP method %s is only for IPv4 addresses", settings.IPMethod)
return fmt.Errorf("IP method %s is only for IPv4 addresses", ipMethod)
}
switch settings.Provider {
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", settings.Provider)
return fmt.Errorf("IPv6 support for %s is not supported yet", provider)
}
}
return nil
}
// Check provider ipmethod is available
if settings.IPMethod == constants.PROVIDER {
switch settings.Provider {
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", settings.IPMethod)
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 !constants.MatchNamecheapPassword(settings.Password) {
return fmt.Errorf("invalid password format")
if err := settingsNamecheapChecks(settings.Password); err != nil {
return err
}
case constants.GODADDY:
switch {
case !constants.MatchGodaddyKey(settings.Key):
return fmt.Errorf("invalid key format")
case !constants.MatchGodaddySecret(settings.Secret):
return fmt.Errorf("invalid secret format")
if err := settingsGoDaddyChecks(settings.Key, settings.Secret); err != nil {
return err
}
case constants.DUCKDNS:
switch {
case !constants.MatchDuckDNSToken(settings.Token):
return fmt.Errorf("invalid token format")
case settings.Host != "@":
return fmt.Errorf(`host can only be "@"`)
if err := settingsDuckDNSChecks(settings.Token, settings.Host); err != nil {
return err
}
case constants.DREAMHOST:
switch {
case !constants.MatchDreamhostKey(settings.Key):
return fmt.Errorf("invalid key format")
case settings.Host != "@":
return fmt.Errorf(`host can only be "@"`)
if err := settingsDreamhostChecks(settings.Key, settings.Host); err != nil {
return err
}
case constants.CLOUDFLARE:
switch {
case len(settings.Key) > 0: // email and key must be provided
switch {
case !constants.MatchCloudflareKey(settings.Key):
return fmt.Errorf("invalid key format")
case !p.verifier.MatchEmail(settings.Email):
return fmt.Errorf("invalid email format")
}
case len(settings.UserServiceKey) > 0: // only user service key
if !constants.MatchCloudflareKey(settings.Key) {
return fmt.Errorf("invalid user service key format")
}
default: // API token only
if !constants.MatchCloudflareToken(settings.Token) {
return fmt.Errorf("invalid API token key format")
}
}
switch {
case len(settings.ZoneIdentifier) == 0:
return fmt.Errorf("zone identifier cannot be empty")
case len(settings.Identifier) == 0:
return fmt.Errorf("identifier cannot be empty")
case settings.Ttl == 0:
return fmt.Errorf("TTL cannot be left to 0")
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:
switch {
case len(settings.Username) == 0:
return fmt.Errorf("username cannot be empty")
case len(settings.Username) > 50:
return fmt.Errorf("username cannot be longer than 50 characters")
case len(settings.Password) == 0:
return fmt.Errorf("password cannot be empty")
case settings.Host == "*":
return fmt.Errorf(`host cannot be "*"`)
if err := settingsNoIPChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
case constants.DNSPOD:
switch {
case len(settings.Token) == 0:
return fmt.Errorf("token cannot be empty")
if err := settingsDNSPodChecks(settings.Password); err != nil {
return err
}
case constants.INFOMANIAK:
switch {
case len(settings.Username) == 0:
return fmt.Errorf("username cannot be empty")
case len(settings.Password) == 0:
return fmt.Errorf("password cannot be empty")
case settings.Host == "*":
return fmt.Errorf(`host cannot be "*"`)
if err := settingsInfomaniakChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
case constants.DDNSSDE:
switch {
case len(settings.Username) == 0:
return fmt.Errorf("username cannot be empty")
case len(settings.Password) == 0:
return fmt.Errorf("password cannot be empty")
case settings.Host == "*":
return fmt.Errorf(`host cannot be "*"`)
if err := settingsDdnssdeChecks(settings.Username, settings.Password, settings.Host); err != nil {
return err
}
default:
return fmt.Errorf("provider %q is not supported", settings.Provider)

View File

@@ -1,90 +1,91 @@
package params
import (
"encoding/json"
"fmt"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
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
}
// GetSettings obtain the update settings from config.json
func (p *params) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
bytes, err := p.readFile(filePath)
if err != nil {
return nil, nil, err
}
var config struct {
Settings []settingsType `json:"settings"`
}
if err := json.Unmarshal(bytes, &config); err != nil {
return nil, nil, err
}
for _, s := range config.Settings {
switch models.Provider(s.Provider) {
case constants.DREAMHOST, constants.DUCKDNS:
s.Host = "@" // only choice available
}
ipMethod := models.IPMethod(s.IPMethod)
// Retro compatibility
if ipMethod == constants.GOOGLE {
p.logger.Warn("IP Method %q is no longer valid, please change it. Defaulting it to %s", constants.GOOGLE, constants.CYCLE)
ipMethod = constants.CYCLE
}
ipVersion := models.IPVersion(s.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4 // 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 := p.isConsistent(setting); err != nil {
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue
}
settings = append(settings, setting)
}
if len(settings) == 0 {
return nil, warnings, fmt.Errorf("no settings found in config.json")
}
return settings, warnings, nil
}
package params
import (
"encoding/json"
"fmt"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
// 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
}
// GetSettings obtain the update settings from config.json
func (r *reader) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
bytes, err := r.readFile(filePath)
if err != nil {
return nil, nil, err
}
var config struct {
Settings []settingsType `json:"settings"`
}
if err := json.Unmarshal(bytes, &config); err != nil {
return nil, nil, err
}
for _, s := range config.Settings {
switch models.Provider(s.Provider) {
case constants.DREAMHOST, constants.DUCKDNS:
s.Host = "@" // only choice available
}
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
}
ipVersion := models.IPVersion(s.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4 // 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 := r.isConsistent(setting); err != nil {
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue
}
settings = append(settings, setting)
}
if len(settings) == 0 {
return nil, warnings, fmt.Errorf("no settings found in config.json")
}
return settings, warnings, nil
}

View File

@@ -1,90 +1,104 @@
package params
import (
"io/ioutil"
"net/url"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/verification"
)
type ParamsReader 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)
GetHTTPTimeout() (duration time.Duration, err error)
// Version getters
GetVersion() string
GetBuildDate() string
GetVcsRef() string
}
type params struct {
envParams libparams.EnvParams
verifier verification.Verifier
logger logging.Logger
readFile func(filename string) ([]byte, error)
}
func NewParamsReader(logger logging.Logger) ParamsReader {
return &params{
envParams: libparams.NewEnvParams(),
verifier: verification.NewVerifier(),
logger: logger,
readFile: ioutil.ReadFile,
}
}
// GetDataDir obtains the data directory from the environment
// variable DATADIR
func (p *params) GetDataDir(currentDir string) (string, error) {
return p.envParams.GetEnv("DATADIR", libparams.Default(currentDir+"/data"))
}
func (p *params) GetListeningPort() (listeningPort, warning string, err error) {
return p.envParams.GetListeningPort()
}
func (p *params) GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error) {
return p.envParams.GetLoggerConfig()
}
func (p *params) GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error) {
return p.envParams.GetGotifyURL()
}
func (p *params) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
return p.envParams.GetGotifyToken()
}
func (p *params) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
return p.envParams.GetRootURL()
}
func (p *params) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, err error) {
// Backward compatibility
n, err := p.envParams.GetEnvInt("DELAY", libparams.Compulsory()) // TODO change to PERIOD
if err == nil { // integer only, treated as seconds
p.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
}
return p.envParams.GetDuration("DELAY", setters...)
}
func (p *params) GetExeDir() (dir string, err error) {
return p.envParams.GetExeDir()
}
func (p *params) GetHTTPTimeout() (duration time.Duration, err error) {
return p.envParams.GetHTTPTimeout(libparams.Default("10s"))
}
package params
import (
"io/ioutil"
"net/url"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/verification"
)
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)
GetHTTPTimeout() (duration time.Duration, err error)
GetBackupPeriod() (duration time.Duration, err error)
GetBackupDirectory() (directory string, err error)
// Version getters
GetVersion() string
GetBuildDate() string
GetVcsRef() string
}
type reader struct {
envParams libparams.EnvParams
verifier verification.Verifier
logger logging.Logger
readFile func(filename string) ([]byte, error)
}
func NewReader(logger logging.Logger) Reader {
return &reader{
envParams: libparams.NewEnvParams(),
verifier: verification.NewVerifier(),
logger: logger,
readFile: ioutil.ReadFile,
}
}
// GetDataDir obtains the data directory from the environment
// variable DATADIR
func (r *reader) GetDataDir(currentDir string) (string, error) {
return r.envParams.GetEnv("DATADIR", libparams.Default(currentDir+"/data"))
}
func (r *reader) GetListeningPort() (listeningPort, warning string, err error) {
return r.envParams.GetListeningPort()
}
func (r *reader) GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error) {
return r.envParams.GetLoggerConfig()
}
func (r *reader) GetGotifyURL(setters ...libparams.GetEnvSetter) (url *url.URL, err error) {
return r.envParams.GetGotifyURL()
}
func (r *reader) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
return r.envParams.GetGotifyToken()
}
func (r *reader) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
return r.envParams.GetRootURL()
}
func (r *reader) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, 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
}
return r.envParams.GetDuration("DELAY", setters...)
}
func (r *reader) GetExeDir() (dir string, err error) {
return r.envParams.GetExeDir()
}
func (r *reader) GetHTTPTimeout() (duration time.Duration, err error) {
return r.envParams.GetHTTPTimeout(libparams.Default("10s"))
}
func (r *reader) GetBackupPeriod() (duration time.Duration, err error) {
s, err := r.envParams.GetEnv("BACKUP_PERIOD", libparams.Default("0"))
if err != nil {
return 0, err
}
return time.ParseDuration(s)
}
func (r *reader) GetBackupDirectory() (directory string, err error) {
return r.envParams.GetEnv("BACKUP_DIRECTORY", libparams.Default("./data"))
}

View File

@@ -4,17 +4,17 @@ import (
libparams "github.com/qdm12/golibs/params"
)
func (p *params) GetVersion() string {
version, _ := p.envParams.GetEnv("VERSION", libparams.Default("?"), libparams.CaseSensitiveValue())
func (r *reader) GetVersion() string {
version, _ := r.envParams.GetEnv("VERSION", libparams.Default("?"), libparams.CaseSensitiveValue())
return version
}
func (p *params) GetBuildDate() string {
buildDate, _ := p.envParams.GetEnv("BUILD_DATE", libparams.Default("?"), libparams.CaseSensitiveValue())
func (r *reader) GetBuildDate() string {
buildDate, _ := r.envParams.GetEnv("BUILD_DATE", libparams.Default("?"), libparams.CaseSensitiveValue())
return buildDate
}
func (p *params) GetVcsRef() string {
buildDate, _ := p.envParams.GetEnv("VCS_REF", libparams.Default("?"), libparams.CaseSensitiveValue())
func (r *reader) GetVcsRef() string {
buildDate, _ := r.envParams.GetEnv("VCS_REF", libparams.Default("?"), libparams.CaseSensitiveValue())
return buildDate
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/persistence/json"
"github.com/qdm12/ddns-updater/internal/persistence/sqlite"
)
type Database interface {
@@ -17,10 +16,6 @@ type Database interface {
Check() error
}
func NewSQLite(dataDir string) (Database, error) {
return sqlite.NewDatabase(dataDir)
}
func NewJSON(dataDir string) (Database, error) {
return json.NewDatabase(dataDir)
}

View File

@@ -9,22 +9,22 @@ import (
"github.com/qdm12/golibs/files"
)
type database struct {
type Database struct {
data dataModel
filepath string
fileManager files.FileManager
sync.RWMutex
}
func (db *database) Close() error {
func (db *Database) Close() error {
db.Lock() // ensure a write operation finishes
defer db.Unlock()
return nil
}
// NewDatabase opens or creates the JSON file database.
func NewDatabase(dataDir string) (*database, error) {
db := database{
func NewDatabase(dataDir string) (*Database, error) {
db := Database{
filepath: dataDir + "/updates.json",
fileManager: files.NewFileManager(),
}
@@ -56,7 +56,7 @@ func NewDatabase(dataDir string) (*database, error) {
return &db, nil
}
func (db *database) Check() error {
func (db *Database) Check() error {
for _, record := range db.data.Records {
switch {
case len(record.Domain) == 0:
@@ -81,7 +81,7 @@ func (db *database) Check() error {
return nil
}
func (db *database) write() error {
func (db *Database) write() error {
data, err := json.MarshalIndent(db.data, "", " ")
if err != nil {
return err

View File

@@ -8,7 +8,7 @@ import (
)
// StoreNewIP stores a new IP address for a certain domain and host.
func (db *database) StoreNewIP(domain, host string, ip net.IP, t time.Time) (err error) {
func (db *Database) StoreNewIP(domain, host string, ip net.IP, t time.Time) (err error) {
db.Lock()
defer db.Unlock()
for i, record := range db.data.Records {
@@ -33,22 +33,19 @@ func (db *database) StoreNewIP(domain, host string, ip net.IP, t time.Time) (err
// GetEvents gets all the IP addresses history for a certain domain and host, in the order
// from oldest to newest
func (db *database) GetEvents(domain, host string) (events []models.HistoryEvent, err error) {
func (db *Database) GetEvents(domain, host string) (events []models.HistoryEvent, err error) {
db.RLock()
defer db.RUnlock()
for _, record := range db.data.Records {
if record.Domain == domain && record.Host == host {
for _, event := range record.Events {
events = append(events, event)
}
return events, nil
return append(events, record.Events...), nil
}
}
return nil, nil
}
// GetAllDomainsHosts gets all the domains and hosts from the database
func (db *database) GetAllDomainsHosts() (domainshosts []models.DomainHost, err error) {
func (db *Database) GetAllDomainsHosts() (domainshosts []models.DomainHost, err error) {
db.RLock()
defer db.RUnlock()
for _, record := range db.data.Records {

View File

@@ -1,49 +0,0 @@
package persistence
import (
"fmt"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
)
func Migrate(source, destination Database, logger logging.Logger) (err error) {
defer func() {
closeErr := source.Close()
if err != nil {
err = fmt.Errorf("%s, %s", err, closeErr)
} else {
err = closeErr
}
}()
type row struct {
domain string
host string
events []models.HistoryEvent
}
var rows []row
domainshosts, err := source.GetAllDomainsHosts()
if err != nil {
return err
}
logger.Info("Migrating %d domain-host tuples", len(domainshosts))
for i := range domainshosts {
domain := domainshosts[i].Domain
host := domainshosts[i].Host
events, err := source.GetEvents(domain, host)
if err != nil {
return err
}
rows = append(rows, row{domain, host, events})
}
for _, r := range rows {
for _, event := range r.events {
destination.StoreNewIP(r.domain, r.host, event.IP, event.Time)
}
}
return destination.Check()
}

View File

@@ -1,39 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"sync"
)
type database struct {
sqlite *sql.DB
sync.Mutex
}
func (db *database) Close() error {
return db.sqlite.Close()
}
// NewDatabase opens or creates the database if necessary.
func NewDatabase(dataDir string) (*database, error) {
sqlite, err := sql.Open("sqlite3", dataDir+"/updates.db")
if err != nil {
return nil, err
}
_, err = sqlite.Exec(
`CREATE TABLE IF NOT EXISTS updates_ips (
domain TEXT NOT NULL,
host TEXT NOT NULL,
ip TEXT NOT NULL,
t_new DATETIME NOT NULL,
t_last DATETIME NOT NULL,
current INTEGER DEFAULT 1 NOT NULL,
PRIMARY KEY(domain, host, ip, t_new)
);`)
return &database{sqlite: sqlite}, err
}
func (db *database) Check() error {
return fmt.Errorf("not implemented")
}

View File

@@ -1,104 +0,0 @@
package sqlite
import (
"fmt"
"net"
"time"
"github.com/qdm12/ddns-updater/internal/models"
)
/* Access to SQLite is NOT thread safe so we use a mutex */
// StoreNewIP stores a new IP address for a certain
// domain and host.
func (db *database) StoreNewIP(domain, host string, ip net.IP, t time.Time) (err error) {
db.Lock()
defer db.Unlock()
// Inserts new IP
_, err = db.sqlite.Exec(
`INSERT INTO updates_ips(domain,host,ip,t_new,t_last)
VALUES(?, ?, ?, ?, ?, ?);`,
domain,
host,
ip.String(),
t,
t, // unneeded but it's hard to modify tables in sqlite
)
return err
}
// GetEvents gets all the IP addresses history for a certain domain and host, in the order
// from oldest to newest
func (db *database) GetEvents(domain, host string) (events []models.HistoryEvent, err error) {
db.Lock()
defer db.Unlock()
rows, err := db.sqlite.Query(
`SELECT ip, t_new
FROM updates_ips
WHERE domain = ? AND host = ?
ORDER BY t_new ASC`,
domain,
host,
)
if err != nil {
return nil, err
}
defer func() {
closeErr := rows.Close()
if err != nil {
err = fmt.Errorf("%s, %s", err, closeErr)
} else {
err = closeErr
}
}()
for rows.Next() {
var ip string
var t time.Time
if err := rows.Scan(&ip, &t); err != nil {
return nil, err
}
events = append(events, models.HistoryEvent{
IP: net.ParseIP(ip),
Time: t,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return events, nil
}
// GetAllDomainsHosts gets all domains and hosts from the database
func (db *database) GetAllDomainsHosts() (domainshosts []models.DomainHost, err error) {
db.Lock()
defer db.Unlock()
rows, err := db.sqlite.Query(`SELECT DISTINCT domain, host FROM updates_ips`)
if err != nil {
return nil, err
}
defer func() {
closeErr := rows.Close()
if err != nil {
err = fmt.Errorf("%s, %s", err, closeErr)
} else {
err = closeErr
}
}()
for rows.Next() {
domainHost := models.DomainHost{}
if err := rows.Scan(&domainHost.Domain, &domainHost.Host); err != nil {
return nil, err
}
domainshosts = append(domainshosts, domainHost)
}
if err := rows.Err(); err != nil {
return nil, err
}
return domainshosts, nil
}
// SetSuccessTime sets the latest successful update time for a particular domain, host.
func (db *database) SetSuccessTime(domain, host string, successTime time.Time) error {
return fmt.Errorf("not implemented") // no plan to migrate back to sqlite
}

View File

@@ -7,19 +7,15 @@ import (
"github.com/kyokomi/emoji"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/params"
)
// Splash returns the welcome spash message
func Splash(paramsReader params.ParamsReader) string {
version := paramsReader.GetVersion()
vcsRef := paramsReader.GetVcsRef()
buildDate := paramsReader.GetBuildDate()
func Splash(version, vcsRef, buildDate string) string {
lines := title()
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("Running version %s built on %s (commit %s)", version, buildDate, vcsRef))
lines = append(lines, "")
lines = append(lines, annoucement()...)
lines = append(lines, announcement()...)
lines = append(lines, "")
lines = append(lines, links()...)
return strings.Join(lines, "\n")
@@ -36,15 +32,15 @@ func title() []string {
}
}
func annoucement() []string {
if len(constants.Annoucement) == 0 {
func announcement() []string {
if len(constants.Announcement) == 0 {
return nil
}
expirationDate, _ := time.Parse("2006-01-02", constants.AnnoucementExpiration) // error covered by a unit test
expirationDate, _ := time.Parse("2006-01-02", constants.AnnouncementExpiration) // error covered by a unit test
if time.Now().After(expirationDate) {
return nil
}
return []string{emoji.Sprint(":mega: ") + constants.Annoucement}
return []string{emoji.Sprint(":mega: ") + constants.Announcement}
}
func links() []string {

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
@@ -20,22 +20,27 @@ func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host
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"`
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),
}
URL := constants.CloudflareURL + "/zones/" + zoneIdentifier + "/dns_records/" + identifier
r, err := network.BuildHTTPPut(
URL,
u.String(),
cloudflarePutBody{
Type: "A",
Name: host,
Content: ip.String(),
Proxied: proxied,
Ttl: ttl,
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)

View File

@@ -4,30 +4,39 @@ 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 {
var hostname string
if host == "@" {
hostname = strings.ToLower(domain)
} else {
hostname = strings.ToLower(host + "." + domain)
u := url.URL{
Scheme: "https",
Host: "www.ddnss.de",
Path: "/upd.php",
}
url := fmt.Sprintf("http://www.ddnss.de/upd.php?user=%s&pwd=%s&host=%s", username, password, hostname)
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
url += fmt.Sprintf("&ip6=%s", ip)
values.Set("ip6", ip.String())
} else {
url += fmt.Sprintf("&ip=%s", ip)
values.Set("ip", ip.String())
}
}
r, err := http.NewRequest(http.MethodGet, url, nil)
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
@@ -42,7 +51,7 @@ func updateDDNSS(client network.Client, domain, host, username, password string,
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", hostname)
return fmt.Errorf("ddnss.de: hostname %q does not exist", fqdn)
case strings.Contains(s, "Updated 1 hostname"):
return nil
default:

View File

@@ -15,20 +15,26 @@ func updateDNSPod(client network.Client, domain, host, token string, ip net.IP)
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
body := bytes.NewBufferString(url.Values{
"login_token": []string{token},
"format": []string{"json"},
"domain": []string{domain},
"length": []string{"200"},
"sub_domain": []string{host},
"record_type": []string{"A"},
}.Encode())
req, err := http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.List", body)
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
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err := client.DoHTTPRequest(req)
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 {
@@ -61,21 +67,24 @@ func updateDNSPod(client network.Client, domain, host, token string, ip net.IP)
if len(recordID) == 0 {
return fmt.Errorf("record not found")
}
body = bytes.NewBufferString(url.Values{
"login_token": []string{token},
"format": []string{"json"},
"domain": []string{domain},
"record_id": []string{recordID},
"value": []string{ip.String()},
"record_line": []string{recordLine},
"sub_domain": []string{host},
}.Encode())
req, err = http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.Ddns", body)
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
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err = client.DoHTTPRequest(req)
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 {

View File

@@ -5,34 +5,16 @@ import (
"fmt"
"net"
"net/http"
"strings"
"net/url"
"github.com/google/uuid"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
)
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")
}
type dreamhostReponse struct {
Result string `json:"result"`
Data string `json:"data"`
}
// List records
url := constants.DreamhostURL + "/?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records"
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhList struct {
const success = "success"
type (
dreamHostRecords struct {
Result string `json:"result"`
Data []struct {
Editable string `json:"editable"`
@@ -41,13 +23,118 @@ func updateDreamhost(client network.Client, domain, key, domainName string, ip n
Value string `json:"value"`
} `json:"data"`
}
if err := json.Unmarshal(content, &dhList); err != nil {
dreamhostReponse struct {
Result string `json:"result"`
Data string `json:"data"`
}
)
func makeDreamhostDefaultValues(key string) (values url.Values) { //nolint:unparam
values.Set("key", key)
values.Set("unique_id", uuid.New().String())
values.Set("format", "json")
return values
}
func listDreamhostRecords(client network.Client, key string) (records dreamHostRecords, err error) {
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-list_records")
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return records, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return records, err
} else if status != http.StatusOK {
return records, fmt.Errorf("HTTP status %d", status)
}
if err := json.Unmarshal(content, &records); err != nil {
return records, err
} else if records.Result != success {
return records, fmt.Errorf(records.Result)
}
return records, nil
}
func removeDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-remove_record")
values.Set("record", domain)
values.Set("type", "A")
values.Set("value", 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
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != success { // this should not happen
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
return nil
}
func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-add_record")
values.Set("record", domain)
values.Set("type", "A")
values.Set("value", 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
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != success {
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
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
} else if dhList.Result != "success" {
return fmt.Errorf(dhList.Result)
}
var oldIP net.IP
for _, data := range dhList.Data {
for _, data := range records.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
@@ -60,42 +147,9 @@ func updateDreamhost(client network.Client, domain, key, domainName string, ip n
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
url = constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + strings.ToLower(domain) + "&type=A&value=" + oldIP.String()
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
if err := removeDreamhostRecord(client, key, domain, oldIP); err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != "success" { // this should not happen
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
}
// Create the right record
url = constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + strings.ToLower(domain) + "&type=A&value=" + ip.String()
r, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
err = json.Unmarshal(content, &dhResponse)
if err != nil {
return err
} else if dhResponse.Result != "success" {
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
return nil
return addDreamhostRecord(client, key, domain, ip)
}

View File

@@ -4,22 +4,31 @@ import (
"fmt"
"net"
"net/http"
"strings"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"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) {
url := constants.DuckDNSURL + "?domains=" + strings.ToLower(domain) + "&token=" + token + "&verbose=true"
if ip != nil {
url += "&ip=" + ip.String()
u := url.URL{
Scheme: "https",
Host: "www.duckdns.org",
Path: "/update",
}
r, err := http.NewRequest(http.MethodGet, url, nil)
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

View File

@@ -5,9 +5,8 @@ import (
"fmt"
"net"
"net/http"
"strings"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
@@ -19,18 +18,16 @@ func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, i
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
URL := constants.GoDaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
r, err := network.BuildHTTPPut(
URL,
[]goDaddyPutBody{
goDaddyPutBody{
ip.String(),
},
},
)
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 {

View File

@@ -4,26 +4,33 @@ 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) {
var hostname string
if host == "@" {
hostname = strings.ToLower(domain)
} else {
hostname = strings.ToLower(host + "." + domain)
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)
}
url := fmt.Sprintf("https://%s:%s@infomaniak.com/nic/update?hostname=%s", username, password, hostname)
if ip != nil {
url += fmt.Sprintf("&myip=%s", ip)
values.Set("myip", ip.String())
}
r, err := http.NewRequest(http.MethodGet, url, nil)
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

View File

@@ -5,21 +5,31 @@ import (
"fmt"
"net"
"net/http"
"strings"
"net/url"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
)
func updateNamecheap(client network.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
url := constants.NamecheapURL + "?host=" + strings.ToLower(host) + "&domain=" + strings.ToLower(domain) + "&password=" + password
if ip != nil {
url += "&ip=" + ip.String()
u := url.URL{
Scheme: "https",
Host: "dynamicdns.park-your-domain.com",
Path: "/update",
// User: url.UserPassword(username, password),
}
r, err := http.NewRequest(http.MethodGet, url, nil)
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

View File

@@ -4,20 +4,27 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"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) {
url := constants.NoIPURL + "?hostname=" + strings.ToLower(hostname)
if ip != nil {
url += "&myip=" + ip.String()
u := url.URL{
Scheme: "https",
Host: "dynupdate.no-ip.com",
Path: "/nic/update",
User: url.UserPassword(username, password),
}
url = strings.Replace(url, "https://", "https://"+username+":"+password+"@", 1)
r, err := http.NewRequest(http.MethodGet, url, nil)
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
}

View File

@@ -1,214 +1,214 @@
package update
import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/qdm12/golibs/logging"
libnetwork "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
"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
}
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
}
type notifyFunc func(priority int, messageArgs ...interface{})
func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
return &updater{
db: db,
logger: logger,
client: client,
notify: notify,
verifier: verification.NewVerifier(),
ipMethods: constants.IPMethodExternalChoices(),
}
}
func (u *updater) Update(id int) error {
record, err := u.db.Select(id)
if err != nil {
return err
}
record.Time = 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 {
record.Message = err.Error()
}
if updateErr := u.db.Update(id, record); updateErr != nil {
return fmt.Errorf("%s, %s", err, updateErr)
}
return err
}
if newIP != nil {
record.History = append(record.History, models.HistoryEvent{
IP: newIP,
Time: time.Now(),
})
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), 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)
}
package update
import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/qdm12/golibs/logging"
libnetwork "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
"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
}
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
}
type notifyFunc func(priority int, messageArgs ...interface{})
func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
return &updater{
db: db,
logger: logger,
client: client,
notify: notify,
verifier: verification.NewVerifier(),
ipMethods: constants.IPMethodExternalChoices(),
}
}
func (u *updater) Update(id int) error {
record, err := u.db.Select(id)
if err != nil {
return err
}
record.Time = 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 {
record.Message = err.Error()
}
if updateErr := u.db.Update(id, record); updateErr != nil {
return fmt.Errorf("%s, %s", err, updateErr)
}
return err
}
if newIP != nil {
record.History = append(record.History, models.HistoryEvent{
IP: newIP,
Time: time.Now(),
})
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), 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

@@ -6,9 +6,10 @@ import (
"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/mocks"
"github.com/qdm12/golibs/network/mock_network"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -64,10 +65,11 @@ func Test_getPublicIP(t *testing.T) {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
client := mock_network.NewMockClient(mockCtrl)
if len(tc.mockURL) != 0 {
client.On("GetContent", tc.mockURL).Return(
tc.mockContent, http.StatusOK, nil).Once()
client.EXPECT().GetContent(tc.mockURL).Return(tc.mockContent, http.StatusOK, nil).Times(1)
}
u := &updater{
client: client,
@@ -81,8 +83,6 @@ func Test_getPublicIP(t *testing.T) {
assert.NoError(t, err)
}
assert.True(t, tc.ip.Equal(ip))
client.AssertExpectations(t)
})
}
}