Refactor entire Go codebase (#32)

- Small UI adjustments
- Only show last 2 previous IP addresses in notifications and UI
- Database uses interfaces to be modular/pluggable in order to move away from sqlite
- Less dependencies, it even uses a switch statement instead of httprouter
- Updated golibs
- Changed default logging format to `console` (zap)
- Better code overall, modular updater and trigger system
- Refactored readme
- CI script improved
This commit is contained in:
Quentin McGaw
2020-02-22 17:21:32 -05:00
committed by GitHub
parent a11a85e7a5
commit bdb0c2bf2e
52 changed files with 1958 additions and 1697 deletions

View File

@@ -1,3 +1 @@
{ {}
"go.inferGopath": false
}

View File

@@ -1,4 +1,4 @@
ARG ALPINE_VERSION=3.10 ARG ALPINE_VERSION=3.11
ARG GO_VERSION=1.13 ARG GO_VERSION=1.13
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
@@ -35,14 +35,17 @@ RUN apk add --update sqlite ca-certificates && \
chmod 700 /updater/data && \ chmod 700 /updater/data && \
chmod 700 /updater/data/updates.db chmod 700 /updater/data/updates.db
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=2 CMD ["/updater/app", "healthcheck"] HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
USER 1000 USER 1000
ENTRYPOINT ["/updater/app"] ENTRYPOINT ["/updater/app"]
ENV DELAY=600 \ ENV DELAY=10m \
ROOT_URL=/ \ ROOT_URL=/ \
LISTENING_PORT=8000 \ LISTENING_PORT=8000 \
LOG_ENCODING=console \ LOG_ENCODING=console \
LOG_LEVEL=info \ LOG_LEVEL=info \
NODE_ID=0 NODE_ID=0 \
HTTP_TIMEOUT=10s \
GOTIFY_URL= \
GOTIFY_TOKEN=
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
COPY --chown=1000 ui/* /updater/ui/ COPY --chown=1000 ui/* /updater/ui/

147
README.md
View File

@@ -2,8 +2,6 @@
*Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod and DuckDNS* *Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod and DuckDNS*
**WARNING: Env variables naming changed slightly, see below**
[![DDNS Updater by Quentin McGaw](https://github.com/qdm12/ddns-updater/raw/master/readme/title.png)](https://hub.docker.com/r/qmcgaw/ddns-updater) [![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://travis-ci.org/qdm12/ddns-updater.svg?branch=master)](https://travis-ci.org/qdm12/ddns-updater)
@@ -24,7 +22,7 @@
![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png) ![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png)
- Lightweight based on a Go binary and *Alpine 3.10* with Sqlite and Ca-Certificates packages - Lightweight based on a Go binary and *Alpine 3.11* with Sqlite and Ca-Certificates packages
- Persistence with a sqlite database to store old IP addresses and previous update status - Persistence with a sqlite database to store old IP addresses and previous update status
- Docker healthcheck verifying the DNS resolution of your domains - Docker healthcheck verifying the DNS resolution of your domains
- Highly configurable - Highly configurable
@@ -80,7 +78,7 @@
} }
``` ```
See more information at the [configuration section](#configuration) See more information in the [configuration section](#configuration)
1. Use the following command: 1. Use the following command:
@@ -96,100 +94,102 @@
## Configuration ## Configuration
### Record configuration Start by having the following content in *config.json*:
The record update updates configuration must be done through the *config.json* mentioned [above](#setup). ```json
{
"settings": [
{
"provider": "",
"domain": "",
"ip_method": "",
},
{
"provider": "",
"domain": "",
"ip_method": "",
}
]
}
```
#### Required parameters for all The following parameters are to be added in *config.json*
- `"provider"` is the DNS provider and can be: For all record update configuration, you need the following:
- `godaddy`
- `namecheap`
- `duckdns`
- `dreamhost`
- `cloudflare`
- `noip`
- `dnspod`
- `"domain"` is your domain name
- `"ip_method"` is the method to obtain your public IP address and can be
- `provider` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap and NoIP**)
- ~`duckduckgo` using [https://duckduckgo.com/?q=ip](https://duckduckgo.com/?q=ip)~ no longer returns your IP
- `google` using [https://google.com/search?q=ip](https://google.com/search?q=ip)
- `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
Please then refer to your specific DNS host provider in the section below for eventual additional required parameters. - `"provider"` is the DNS provider and can be `"godaddy"`, `"namecheap"`, `"duckdns"`, `"dreamhost"`, `"cloudflare"`, `"noip"`, or `"dnspod"`
- `"domain"`
- `"ip_method"` is the method to obtain your public IP address and can be:
- `"provider"` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap and NoIP**)
- `"google"` using [https://google.com/search?q=ip](https://google.com/search?q=ip)
- `"opendns"` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
#### Optional parameters for all You can optionnally add the parameters:
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable which itself defaults to 5 minutes. - `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable value.
- `"no_dns_lookup"` is a boolean to prevent the regular Docker healthcheck from running a DNS lookup on your domain. This is useful in some corner cases. - `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the periodic Docker healthcheck from running a DNS lookup on your domain.
#### Namecheap For each DNS provider exist some specific parameters you need to add, as described below:
- Required: Namecheap:
- `"host"` is your host and can be a subdomain, `@` or `*` generally
- `"password"`
#### Cloudflare - `"host"` is your host and can be a subdomain, `@` or `*` generally
- `"password"`
- Required: Cloudflare:
- `"zone_identifier"`
- `"identifier"`
- `"host"` is your host and can be a subdomain, `@` or `*` generally
- `"ttl"` integer value for record TTL (specify 1 for automatic)
- Either:
- Email `"email"` and Key `"key"`
- User service key `"user_service_key"`
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone.
- Optional:
- `"proxied"` is a boolean to use the proxy services of Cloudflare
#### GoDaddy - `"zone_identifier"`
- `"identifier"`
- `"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"`
- 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
- Required: GoDaddy:
- `"host"` is your host and can be a subdomain, `@` or `*` generally
- `"key"`
- `"secret"`
#### DuckDNS - `"host"` is your host and can be a subdomain, `@` or `*` generally
- `"key"`
- `"secret"`
- Required: DuckDNS:
- `"token"`
#### Dreamhost - `"token"`
- Required: Dreamhost:
- `"key"`
#### NoIP - `"key"`
- Required: NoIP:
- `"host"` is your host and can be a subdomain or `@`
- `"username"` which is your username
- `"password"`
#### DNSPOD - `"host"` is your host and can be a subdomain or `@`
- `"username"`
- `"password"`
- Required: DNSPOD:
- `"host"` is your host and can be a subdomain or `@`
- `"token"` - `"host"` is your host and can be a subdomain or `@`
- `"token"`
### Environment variables ### Environment variables
| Environment variable | Default | Description | | Environment variable | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| `DELAY` | `300` | Delay between updates in seconds | | `DELAY` | `10m` | Default delay between updates, following [this format](https://golang.org/pkg/time/#ParseDuration) |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) | | `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI | | `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
| `LOG_ENCODING` | `json` | Format of logging, `json` or `human` | | `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
| `LOG_LEVEL` | `info` | Level of logging, `info`, ~`success`~, `warning` or `error` | | `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer | | `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
| `GOTIFY_URL` | | HTTP(s) URL to your Gotify server | | `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
| `GOTIFY_TOKEN` | | Token to access your Gotify server | | `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
### Host firewall ### Host firewall
This container needs the following ports: If you have a host firewall in place, this container needs the following ports:
- TCP 443 outbound for outbound HTTPS - TCP 443 outbound for outbound HTTPS
- TCP 80 outbound if you use a local unsecured HTTP connection to your Gotify server - TCP 80 outbound if you use a local unsecured HTTP connection to your Gotify server
@@ -278,13 +278,9 @@ You can now fill in the necessary parameters in *config.json*
Special thanks to @Starttoaster for helping out with the [documentation](https://gist.github.com/Starttoaster/07d568c2a99ad7631dd776688c988326) and testing. Special thanks to @Starttoaster for helping out with the [documentation](https://gist.github.com/Starttoaster/07d568c2a99ad7631dd776688c988326) and testing.
### NoIP
*Awaiting a contribution*
## Gotify ## Gotify
[![https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/gotify.png](Gotify)](https://gotify.net) [![Gotify](https://github.com/qdm12/ddns-updater/blob/master/readme/gotify.png?raw=true)](https://gotify.net)
[**Gotify**](https://gotify.net) is a simple server for sending and receiving messages, and it is **free**, **private** and **open source** [**Gotify**](https://gotify.net) is a simple server for sending and receiving messages, and it is **free**, **private** and **open source**
- It has an [Android app](https://play.google.com/store/apps/details?id=com.github.gotify) to receive notifications - It has an [Android app](https://play.google.com/store/apps/details?id=com.github.gotify) to receive notifications
@@ -307,8 +303,6 @@ To set it up with DDNS updater:
[![GoDaddy DNS management](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddydnsmanagement.png)](https://dcc.godaddy.com/manage/) [![GoDaddy DNS management](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddydnsmanagement.png)](https://dcc.godaddy.com/manage/)
You might want to try to change the IP address to another one to see if the update actually occurs. You might want to try to change the IP address to another one to see if the update actually occurs.
- Namecheap: *awaiting contribution*
- DuckDNS: *awaiting contribution*
## Used in external projects ## Used in external projects
@@ -324,13 +318,16 @@ To set it up with DDNS updater:
1. In Visual Studio Code, press on `F1` and select `Remote-Containers: Open Folder in Container...` 1. In Visual Studio Code, press on `F1` and select `Remote-Containers: Open Folder in Container...`
1. Your dev environment is ready to go!... and it's running in a container :+1: 1. Your dev environment is ready to go!... and it's running in a container :+1:
You can probably start looking at the cmd/updater/main.go file which is the entrypoint of the program.
## TODOs ## TODOs
- [ ] Changed from sqlite to bolt or similar
- [ ] Support Infomaniak.com
- [ ] icon.ico for webpage - [ ] icon.ico for webpage
- [ ] Record events log - [ ] Record events log
- [ ] Use internal package instead of pkg - [ ] Use internal package instead of pkg
- [ ] Hot reload of config.json - [ ] Hot reload of config.json
- [ ] Changed from sqlite to bolt or similar
- [ ] Unit tests - [ ] Unit tests
- [ ] Other types or records - [ ] Other types or records
- [ ] ReactJS frontend - [ ] ReactJS frontend

12
ci.sh
View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
if [ "$TRAVIS_PULL_REQUEST" = "true" ] || [ "$TRAVIS_BRANCH" != "master" ]; then if [ "$TRAVIS_PULL_REQUEST" = "true" ]; then
echo "Building pull request without pushing to Docker Hub"
docker buildx build \ docker buildx build \
--progress plain \ --progress plain \
--platform=linux/amd64,linux/386,linux/arm64,linux/arm/v7 \ --platform=linux/amd64,linux/386,linux/arm64,linux/arm/v7 \
@@ -8,7 +9,14 @@ if [ "$TRAVIS_PULL_REQUEST" = "true" ] || [ "$TRAVIS_BRANCH" != "master" ]; then
exit $? exit $?
fi fi
echo $DOCKER_PASSWORD | docker login -u qmcgaw --password-stdin &> /dev/null echo $DOCKER_PASSWORD | docker login -u qmcgaw --password-stdin &> /dev/null
TAG="${TRAVIS_TAG:-latest}" TAG="$TRAVIS_TAG"
if [ -z $TAG ]; then
if [ "$TRAVIS_BRANCH" = "master" ]; then
TAG=latest
else
TAG="$TRAVIS_BRANCH"
fi
fi
echo "Building Docker images for \"$DOCKER_REPO:$TAG\"" echo "Building Docker images for \"$DOCKER_REPO:$TAG\""
docker buildx build \ docker buildx build \
--progress plain \ --progress plain \

View File

@@ -1,6 +1,9 @@
package main package main
import ( import (
"context"
"net"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"fmt" "fmt"
@@ -8,27 +11,41 @@ import (
"os" "os"
"time" "time"
"github.com/qdm12/ddns-updater/internal/database" "github.com/kyokomi/emoji"
"github.com/qdm12/ddns-updater/internal/env"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/params"
"github.com/qdm12/ddns-updater/internal/router"
"github.com/qdm12/ddns-updater/internal/update"
"github.com/qdm12/golibs/admin" "github.com/qdm12/golibs/admin"
"github.com/qdm12/golibs/healthcheck" libhealthcheck "github.com/qdm12/golibs/healthcheck"
"github.com/qdm12/golibs/logging" "github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network" "github.com/qdm12/golibs/network"
libparams "github.com/qdm12/golibs/params" libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/server" "github.com/qdm12/golibs/server"
"github.com/kyokomi/emoji"
"github.com/qdm12/golibs/signals" "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/trigger"
"github.com/qdm12/ddns-updater/internal/update"
) )
func main() { func main() {
if healthcheck.Mode(os.Args) { logger, err := logging.NewLogger(logging.ConsoleEncoding, logging.InfoLevel, -1)
if err := healthcheck.Query(); err != nil { if err != nil {
logging.Err(err) 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) {
if err := libhealthcheck.Query(); err != nil {
logger.Error(err)
os.Exit(1) os.Exit(1)
} }
os.Exit(0) os.Exit(0)
@@ -39,85 +56,84 @@ func main() {
fmt.Println("######## Give some " + emoji.Sprint(":heart:") + "at #########") fmt.Println("######## Give some " + emoji.Sprint(":heart:") + "at #########")
fmt.Println("# github.com/qdm12/ddns-updater #") fmt.Println("# github.com/qdm12/ddns-updater #")
fmt.Print("#################################\n\n") fmt.Print("#################################\n\n")
envParams := libparams.NewEnvParams() e := env.NewEnv(logger)
encoding, level, nodeID, err := envParams.GetLoggerConfig() gotify, err := setupGotify(paramsReader)
if err != nil { e.FatalOnError(err)
logging.Error(err.Error()) e.SetGotify(gotify)
} else { listeningPort, warning, err := paramsReader.GetListeningPort()
logging.InitLogger(encoding, level, nodeID) e.FatalOnError(err)
if len(warning) > 0 {
logger.Warn(warning)
} }
var e env.Env rootURL, err := paramsReader.GetRootURL()
HTTPTimeout, err := envParams.GetHTTPTimeout(time.Millisecond, libparams.Default("3000"))
e.CheckError(err)
e.Client = network.NewClient(HTTPTimeout)
e.Gotify, err = setupGotify(envParams)
listeningPort, err := envParams.GetListeningPort()
e.FatalOnError(err) e.FatalOnError(err)
rootURL, err := envParams.GetRootURL() defaultPeriod, err := paramsReader.GetDelay(libparams.Default("10m"))
e.FatalOnError(err) e.FatalOnError(err)
delay, err := envParams.GetDuration("DELAY", time.Second, libparams.Default("600")) dir, err := paramsReader.GetExeDir()
e.FatalOnError(err) e.FatalOnError(err)
dir, err := envParams.GetExeDir() dataDir, err := paramsReader.GetDataDir(dir)
e.FatalOnError(err) e.FatalOnError(err)
dataDir, err := params.GetDataDir(envParams, dir) persistentDB, err := persistence.NewSQLite(dataDir)
e.FatalOnError(err) e.FatalOnError(err)
e.SQL, err = database.NewDB(dataDir)
e.FatalOnError(err)
defer e.SQL.Close()
go signals.WaitForExit(e.ShutdownFromSignal) go signals.WaitForExit(e.ShutdownFromSignal)
settings, warnings, err := params.GetSettings(dataDir + "/config.json") settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
for _, w := range warnings { for _, w := range warnings {
e.Warn(w) e.Warn(w)
} }
if err != nil { if err != nil {
e.Fatal(err) e.Fatal(err)
} }
logging.Infof("Found %d settings to update records", len(settings)) logger.Info("Found %d settings to update records", len(settings))
for _, err := range network.NewConnectivity(5 * time.Second).Checks("google.com") { for _, err := range network.NewConnectivity(5 * time.Second).Checks("google.com") {
e.Warn(err) e.Warn(err)
} }
var recordsConfigs []models.RecordConfigType var records []models.Record
for _, s := range settings { idToPeriod := make(map[int]time.Duration)
logging.Infof("Reading history from database: domain %s host %s", s.Domain, s.Host) for id, setting := range settings {
ips, tSuccess, err := e.SQL.GetIps(s.Domain, s.Host) logger.Info("Reading history from database: domain %s host %s", setting.Domain, setting.Host)
ips, successTime, err := persistentDB.GetIPs(setting.Domain, setting.Host)
if err != nil { if err != nil {
e.FatalOnError(err) e.FatalOnError(err)
} }
recordsConfigs = append(recordsConfigs, models.NewRecordConfig(s, ips, tSuccess)) records = append(records, models.NewRecord(setting, ips, successTime))
idToPeriod[id] = defaultPeriod
if setting.Delay > 0 {
idToPeriod[id] = setting.Delay
}
} }
chForce := make(chan struct{}) HTTPTimeout, err := paramsReader.GetHTTPTimeout()
chQuit := make(chan struct{}) e.FatalOnError(err)
defer close(chForce) client := network.NewClient(HTTPTimeout)
go update.TriggerServer(delay, chForce, chQuit, recordsConfigs, e.Client, e.SQL, e.Gotify) db := data.NewDatabase(records, persistentDB)
chForce <- struct{}{} e.SetDb(db)
productionRouter := router.CreateRouter(rootURL, dir, chForce, recordsConfigs, e.Gotify) updater := update.NewUpdater(db, logger, client, e.Notify)
healthcheckRouter := healthcheck.CreateRouter(func() error { ctx, cancel := context.WithCancel(context.Background())
return router.IsHealthy(recordsConfigs) 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)
}) })
logging.Infof("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL) logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL)
if e.Gotify != nil { e.Notify(1, fmt.Sprintf("Just launched\nIt has %d records to watch", len(records)))
e.Gotify.Notify("DDNS Updater", 1, "Just launched\nIt has %d records to watch", len(recordsConfigs))
}
serverErrs := server.RunServers( serverErrs := server.RunServers(
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionRouter}, server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionHandlerFunc},
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckRouter}, server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
) )
for _, err := range serverErrs {
e.CheckError(err)
}
if len(serverErrs) > 0 { if len(serverErrs) > 0 {
e.Fatal(serverErrs) e.Fatal(serverErrs)
} }
} }
func setupGotify(envParams libparams.EnvParams) (admin.Gotify, error) { func setupGotify(paramsReader params.ParamsReader) (admin.Gotify, error) {
URL, err := envParams.GetGotifyURL() URL, err := paramsReader.GetGotifyURL()
if err != nil { if err != nil {
return nil, err return nil, err
} else if URL == nil { } else if URL == nil {
return nil, nil return nil, nil
} }
token, err := envParams.GetGotifyToken() token, err := paramsReader.GetGotifyToken()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -9,10 +9,13 @@ services:
volumes: volumes:
- ./data:/updater/data - ./data:/updater/data
environment: environment:
- DELAY=300 - DELAY=300s
- ROOTURL= - ROOT_URL=/
- LISTENINGPORT=8000 - LISTENINGPORT=8000
- LOGGING=human - LOG_ENCODING=console
- NODEID=0 - LOG_LEVEL=info
- LOGLEVEL= - NODE_ID=0
- HTTP_TIMEOUT=10s
- GOTIFY_URL=
- GOTIFY_TOKEN=
restart: always restart: always

3
go.mod
View File

@@ -4,8 +4,7 @@ go 1.13
require ( require (
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/julienschmidt/httprouter v1.3.0
github.com/kyokomi/emoji v2.1.0+incompatible github.com/kyokomi/emoji v2.1.0+incompatible
github.com/mattn/go-sqlite3 v1.10.0 github.com/mattn/go-sqlite3 v1.10.0
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23 github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd
) )

24
go.sum
View File

@@ -37,16 +37,12 @@ github.com/go-openapi/validate v0.17.0 h1:pqoViQz3YLOGIhAmD0N4Lt6pa/3Gnj3ymKqQwq
github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= 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/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gotify/go-api-client/v2 v2.0.4 h1:0w8skCr8aLBDKaQDg31LKKHUGF7rt7zdRpR+6cqIAlE= github.com/gotify/go-api-client/v2 v2.0.4 h1:0w8skCr8aLBDKaQDg31LKKHUGF7rt7zdRpR+6cqIAlE=
github.com/gotify/go-api-client/v2 v2.0.4/go.mod h1:VKiah/UK20bXsr0JObE1eBVLW44zbBouzjuri9iwjFU= github.com/gotify/go-api-client/v2 v2.0.4/go.mod h1:VKiah/UK20bXsr0JObE1eBVLW44zbBouzjuri9iwjFU=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -61,6 +57,8 @@ github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK86
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc=
github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY= github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY=
@@ -68,8 +66,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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23 h1:PdABK6xsIrNLtUgbB1pRL3PvmZagwjx9aWkvKDx5IA0= github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd h1:6Ip2U6WYjPnrdB/gdz/YXki/Z3WqLDc06gJe852x2IQ=
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23/go.mod h1:oNw3Ie4T0wyeBk9fK8vOe5tKdWG3bEcII6RlQjw7JIs= github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd/go.mod h1:YULaFjj6VGmhjak6f35sUWwEleHUmngN5IQ3kdvd6XE=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -78,17 +76,17 @@ 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM= go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc=
go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -117,7 +115,5 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= 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= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

View File

@@ -0,0 +1,17 @@
package constants
import "github.com/qdm12/ddns-updater/internal/models"
const (
IPMETHODPROVIDER models.IPMethod = "provider"
IPMETHODGOOGLE models.IPMethod = "google"
IPMETHODOPENDNS models.IPMethod = "opendns"
)
func IPMethodChoices() (choices []models.IPMethod) {
return []models.IPMethod{
IPMETHODPROVIDER,
IPMETHODGOOGLE,
IPMETHODOPENDNS,
}
}

View File

@@ -0,0 +1,26 @@
package constants
import "github.com/qdm12/ddns-updater/internal/models"
// All possible provider values
const (
PROVIDERGODADDY models.Provider = "godaddy"
PROVIDERNAMECHEAP models.Provider = "namecheap"
PROVIDERDUCKDNS models.Provider = "duckdns"
PROVIDERDREAMHOST models.Provider = "dreamhost"
PROVIDERCLOUDFLARE models.Provider = "cloudflare"
PROVIDERNOIP models.Provider = "noip"
PROVIDERDNSPOD models.Provider = "dnspod"
)
func ProviderChoices() (choices []models.Provider) {
return []models.Provider{
PROVIDERGODADDY,
PROVIDERNAMECHEAP,
PROVIDERDUCKDNS,
PROVIDERDREAMHOST,
PROVIDERCLOUDFLARE,
PROVIDERNOIP,
PROVIDERDNSPOD,
}
}

View File

@@ -0,0 +1,46 @@
package constants
import "regexp"
const (
RegexGoDaddyKey string = `[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}`
RegexGodaddySecret string = `[A-Za-z0-9]{22}`
RegexDuckDNSToken string = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}`
RegexNamecheapPassword string = `[a-f0-9]{32}`
RegexDreamhostKey string = `[a-zA-Z0-9]{16}`
RegexCloudflareKey string = `[a-zA-Z0-9]+`
RegexCloudflareUserServiceKey string = `v1\.0.+`
RegexCloudflareToken string = `[a-zA-Z0-9_]{40}`
)
func MatchGodaddyKey(s string) bool {
return regexp.MustCompile("^" + RegexGoDaddyKey + "$").MatchString(s)
}
func MatchGodaddySecret(s string) bool {
return regexp.MustCompile("^" + RegexGodaddySecret + "$").MatchString(s)
}
func MatchDuckDNSToken(s string) bool {
return regexp.MustCompile("^" + RegexDuckDNSToken + "$").MatchString(s)
}
func MatchNamecheapPassword(s string) bool {
return regexp.MustCompile("^" + RegexNamecheapPassword + "$").MatchString(s)
}
func MatchDreamhostKey(s string) bool {
return regexp.MustCompile("^" + RegexDreamhostKey + "$").MatchString(s)
}
func MatchCloudflareKey(s string) bool {
return regexp.MustCompile("^" + RegexCloudflareKey + "$").MatchString(s)
}
func MatchCloudflareUserServiceKey(s string) bool {
return regexp.MustCompile("^" + RegexCloudflareUserServiceKey + "$").MatchString(s)
}
func MatchCloudflareToken(s string) bool {
return regexp.MustCompile("^" + RegexCloudflareToken + "$").MatchString(s)
}

View File

@@ -0,0 +1,17 @@
package constants
import "github.com/qdm12/ddns-updater/internal/models"
const (
FAIL models.Status = "failure"
SUCCESS models.Status = "success"
UPTODATE models.Status = "up to date"
UPDATING models.Status = "updating"
)
const (
HTML_FAIL string = `<font color="red"><b>Failure</b></font>`
HTML_SUCCESS string = `<font color="green"><b>Success</b></font>`
HTML_UPTODATE string = `<font color="#00CC66"><b>Up to date</b></font>`
HTML_UPDATING string = `<font color="orange"><b>Updating</b></font>`
)

10
internal/constants/url.go Normal file
View File

@@ -0,0 +1,10 @@
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"
)

40
internal/data/data.go Normal file
View File

@@ -0,0 +1,40 @@
package data
import (
"net"
"sync"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/persistence"
)
type Database interface {
Close() error
Insert(record models.Record) (id int)
Select(id int) (record models.Record, err error)
SelectAll() (records []models.Record)
Update(id int, record models.Record) error
// From persistence database
GetIPs(domain, host string) (IPs []net.IP, timeSuccess time.Time, err error)
}
type database struct {
data []models.Record
sync.RWMutex
persistentDB persistence.Database
}
// NewDatabase creates a new in memory database
func NewDatabase(data []models.Record, persistentDB persistence.Database) Database {
return &database{
data: data,
persistentDB: persistentDB,
}
}
func (db *database) Close() error {
db.Lock() // ensure write operation finishes
defer db.Unlock()
return db.persistentDB.Close()
}

32
internal/data/memory.go Normal file
View File

@@ -0,0 +1,32 @@
package data
import (
"fmt"
"github.com/qdm12/ddns-updater/internal/models"
)
func (db *database) Insert(record models.Record) (id int) {
db.Lock()
defer db.Unlock()
db.data = append(db.data, record)
return len(db.data) - 1
}
func (db *database) Select(id int) (record models.Record, err error) {
db.RLock()
defer db.RUnlock()
if id < 0 {
return record, fmt.Errorf("id %d cannot be lower than 0", id)
}
if id > len(db.data)-1 {
return record, fmt.Errorf("no record config found for id %d", id)
}
return db.data[id], nil
}
func (db *database) SelectAll() (records []models.Record) {
db.RLock()
defer db.RUnlock()
return db.data
}

View File

@@ -0,0 +1,38 @@
package data
import (
"fmt"
"net"
"time"
"github.com/qdm12/ddns-updater/internal/models"
)
func (db *database) GetIPs(domain, host string) (IPs []net.IP, timeSuccess time.Time, err error) {
return db.persistentDB.GetIPs(domain, host)
}
func (db *database) Update(id int, record models.Record) error {
db.Lock()
defer db.Unlock()
if id < 0 {
return fmt.Errorf("id %d cannot be lower than 0", id)
}
if id > len(db.data)-1 {
return fmt.Errorf("no record config found for id %d", id)
}
currentCount := len(db.data[id].History.IPs)
newCount := len(record.History.IPs)
db.data[id] = record
// new IP address added
if newCount > currentCount {
if err := db.persistentDB.StoreNewIP(
record.Settings.Domain,
record.Settings.Host,
record.History.GetCurrentIP(),
); err != nil {
return err
}
}
return nil
}

View File

@@ -1,49 +0,0 @@
package database
import (
"database/sql"
"sync"
"time"
)
// A sqlite database is used to store previous IPs, when re launching the program.
// SQL represents the database store actions.
// It is implemented with the database struct and methods.
// WARNING: Use in one single go routine, it is not thread safe !
type SQL interface {
Lock()
Unlock()
UpdateIPTime(domain, host, ip string) (err error)
StoreNewIP(domain, host, ip string) (err error)
GetIps(domain, host string) (ips []string, tNew time.Time, err error)
Close() error
}
type database struct {
sqlite *sql.DB
sync.Mutex
}
func (db *database) Close() error {
return db.sqlite.Close()
}
// NewDB opens or creates the database if necessary.
func NewDB(dataDir string) (SQL, 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
}

View File

@@ -1,92 +0,0 @@
package database
import (
"time"
)
/* All these methods must be called by a single go routine as they are not
thread safe because of SQLite */
// UpdateIPTime updates the latest same IP update time for a certain
// domain, host and IP tuple.
func (db *database) UpdateIPTime(domain, host, ip string) (err error) {
db.Lock()
defer db.Unlock()
_, err = db.sqlite.Exec(
`UPDATE updates_ips
SET t_last = ?
WHERE domain = ? AND host = ? AND ip = ? AND current = 1`,
time.Now(),
domain,
host,
ip,
)
return err
}
// StoreNewIP stores a new IP address for a certain
// domain and host.
func (db *database) StoreNewIP(domain, host, ip string) (err error) {
// Disable the current IP
db.Lock()
defer db.Unlock()
_, err = db.sqlite.Exec(
`UPDATE updates_ips
SET current = 0
WHERE domain = ? AND host = ? AND current = 1`,
domain,
host,
)
if err != nil {
return err
}
// Inserts new IP
_, err = db.sqlite.Exec(
`INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current)
VALUES(?, ?, ?, ?, ?, ?);`,
domain,
host,
ip,
time.Now(),
time.Now(),
1,
)
return err
}
// GetIps gets all the IP addresses history for a certain
// domain and host.
func (db *database) GetIps(domain, host string) (ips []string, tNew time.Time, 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 DESC`,
domain,
host,
)
if err != nil {
return nil, tNew, err
}
defer rows.Close()
var ip string
var t time.Time
var tNewSet bool
for rows.Next() {
err = rows.Scan(&ip, &t)
if err != nil {
return ips, tNew, err
}
if !tNewSet {
tNew = t
tNewSet = true
}
ips = append(ips, ip)
}
if !tNewSet {
tNew = time.Now()
}
return ips, tNew, rows.Err()
}

221
internal/env/env.go vendored
View File

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

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

View File

@@ -0,0 +1,45 @@
package healthcheck
import (
"fmt"
"net"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/golibs/logging"
)
type lookupIPFunc func(host string) ([]net.IP, error)
// IsHealthy checks all the records were updated successfully and returns an error if not
func IsHealthy(db data.Database, lookupIP lookupIPFunc, logger logging.Logger) (err error) {
defer func() {
if err != nil {
logger.Warn("unhealthy: %s", err)
}
}()
records := db.SelectAll()
for _, record := range records {
if record.Status == constants.FAIL {
return fmt.Errorf("%s", record.String())
} else if record.Settings.NoDNSLookup {
continue
}
lookedUpIPs, err := lookupIP(record.Settings.BuildDomainName())
if err != nil {
return err
}
currentIP := record.History.GetCurrentIP()
if currentIP == nil {
return fmt.Errorf("no set IP address found")
}
for _, lookedUpIP := range lookedUpIPs {
if !lookedUpIP.Equal(currentIP) {
return fmt.Errorf(
"lookup IP address of %s is %s instead of %s",
record.Settings.BuildDomainName(), lookedUpIP, currentIP)
}
}
}
return nil
}

103
internal/html/html.go Normal file
View File

@@ -0,0 +1,103 @@
package html
import (
"fmt"
"strings"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
func ConvertRecord(record models.Record) models.HTMLRow {
row := models.HTMLRow{
Domain: convertDomain(record.Settings.BuildDomainName()),
Host: record.Settings.Host,
Provider: convertProvider(record.Settings.Provider),
IPMethod: convertIPMethod(record.Settings.IPMethod, record.Settings.Provider),
}
message := record.Message
if record.Status == constants.UPTODATE {
message = "no IP change for " + record.History.GetDurationSinceSuccess()
}
if len(message) > 0 {
message = fmt.Sprintf("(%s)", message)
}
if len(record.Status) == 0 {
row.Status = "N/A"
} else {
row.Status = fmt.Sprintf("%s %s, %s",
convertStatus(record.Status),
message,
time.Since(record.Time).Round(time.Second).String()+" ago")
}
currentIP := record.History.GetCurrentIP()
if currentIP != nil {
row.CurrentIP = `<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>"
} else {
row.CurrentIP = "N/A"
}
previousIPs := record.History.GetPreviousIPs()
row.PreviousIPs = "N/A"
if len(previousIPs) > 0 {
var previousIPsStr []string
const maxPreviousIPs = 2
for i, previousIP := range previousIPs {
if i == maxPreviousIPs {
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
break
}
previousIPsStr = append(previousIPsStr, previousIP.String())
}
row.PreviousIPs = strings.Join(previousIPsStr, ", ")
}
return row
}
func convertStatus(status models.Status) string {
switch status {
case constants.SUCCESS:
return constants.HTML_SUCCESS
case constants.FAIL:
return constants.HTML_FAIL
case constants.UPTODATE:
return constants.HTML_UPTODATE
case constants.UPDATING:
return constants.HTML_UPDATING
default:
return "Unknown status"
}
}
func convertProvider(provider models.Provider) string {
switch provider {
case constants.PROVIDERNAMECHEAP:
return "<a href=\"https://namecheap.com\">Namecheap</a>"
case constants.PROVIDERGODADDY:
return "<a href=\"https://godaddy.com\">GoDaddy</a>"
case constants.PROVIDERDUCKDNS:
return "<a href=\"https://duckdns.org\">DuckDNS</a>"
case constants.PROVIDERDREAMHOST:
return "<a href=\"https://https://www.dreamhost.com/\">Dreamhost</a>"
default:
return string(provider)
}
}
func convertIPMethod(IPMethod models.IPMethod, provider models.Provider) string {
// TODO map to icons
switch IPMethod {
case constants.IPMETHODPROVIDER:
return convertProvider(provider)
case constants.IPMETHODGOOGLE:
return "<a href=\"https://google.com/search?q=ip\">Google</a>"
case constants.IPMETHODOPENDNS:
return "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
default:
return string(IPMethod)
}
}
func convertDomain(domain string) string {
return "<a href=\"http://" + domain + "\">" + domain + "</a>"
}

10
internal/models/alias.go Normal file
View File

@@ -0,0 +1,10 @@
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
)

View File

@@ -2,71 +2,80 @@ package models
import ( import (
"fmt" "fmt"
"sync" "net"
"strings"
"time" "time"
) )
type historyType struct { // History contains current and previous IP address for a particular record
ips []string // current and previous ips // with the latest success time
tSuccess time.Time type History struct {
sync.RWMutex IPs []net.IP // current and previous ips
SuccessTime time.Time
} }
func newHistory(ips []string, tSuccess time.Time) historyType { // GetPreviousIPs returns an antichronological list of previous
return historyType{ // IP addresses if there is any.
ips: ips, func (h *History) GetPreviousIPs() []net.IP {
tSuccess: tSuccess, if len(h.IPs) <= 1 {
return nil
} }
IPs := make([]net.IP, len(h.IPs)-1)
for i := len(h.IPs) - 2; i >= 0; i-- {
IPs[i] = h.IPs[i]
}
return IPs
} }
func (history *historyType) PrependIP(ip string) { // GetCurrentIP returns the current IP address (latest in history)
history.Lock() func (h *History) GetCurrentIP() net.IP {
defer history.Unlock() if len(h.IPs) < 1 {
history.ips = append([]string{ip}, history.ips...) return nil
}
return h.IPs[len(h.IPs)-1]
} }
func (history *historyType) SetTSuccess(t time.Time) { func (h *History) GetDurationSinceSuccess() string {
history.Lock() duration := time.Since(h.SuccessTime)
defer history.Unlock() switch {
history.tSuccess = t case duration < time.Minute:
}
func (history *historyType) GetIPs() []string {
history.RLock()
defer history.RUnlock()
return history.ips
}
func (history *historyType) GetTSuccessDuration() string {
history.RLock()
defer history.RUnlock()
return durationString(history.tSuccess)
}
func durationString(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return fmt.Sprintf("%ds", int(duration.Round(time.Second).Seconds())) return fmt.Sprintf("%ds", int(duration.Round(time.Second).Seconds()))
} else if duration < time.Hour { case duration < time.Hour:
return fmt.Sprintf("%dm", int(duration.Round(time.Minute).Minutes())) return fmt.Sprintf("%dm", int(duration.Round(time.Minute).Minutes()))
} else if duration < 24*time.Hour { case duration < 24*time.Hour:
return fmt.Sprintf("%dh", int(duration.Round(time.Hour).Hours())) return fmt.Sprintf("%dh", int(duration.Round(time.Hour).Hours()))
} else { default:
return fmt.Sprintf("%dd", int(duration.Round(time.Hour*24).Hours()/24)) return fmt.Sprintf("%dd", int(duration.Round(time.Hour*24).Hours()/24))
} }
} }
func (history *historyType) String() (s string) { func (h *History) String() (s string) {
history.RLock() currentIP := h.GetCurrentIP()
defer history.RUnlock() if currentIP == nil {
if len(history.ips) > 0 { return ""
s += "Last success update: " + history.tSuccess.Format("2006-01-02 15:04:05 MST") + "; Current and previous IPs: "
for i := range history.ips {
s += history.ips[i]
if i != len(history.ips)-1 {
s += ","
}
}
} }
return s successTime := h.SuccessTime
previousIPs := h.GetPreviousIPs()
if len(previousIPs) == 0 {
return fmt.Sprintf(
"Last success update: %s; IP: %s",
successTime.Format("2006-01-02 15:04:05 MST"),
currentIP.String(),
)
}
const maxDisplay = 4
previousIPsStr := []string{}
for i, IP := range previousIPs {
if i == maxDisplay {
previousIPsStr = append(previousIPsStr, fmt.Sprintf("...(%d more)", len(previousIPs)-maxDisplay))
break
}
previousIPsStr = append(previousIPsStr, IP.String())
}
return fmt.Sprintf(
"Last success update: %s; IP: %s; Previous IPs: %s",
successTime.Format("2006-01-02 15:04:05 MST"),
currentIP.String(),
strings.Join(previousIPsStr, ","),
)
} }

View File

@@ -9,19 +9,11 @@ type HTMLData struct {
// HTMLRow contains HTML fields to be rendered // HTMLRow contains HTML fields to be rendered
// It is exported so that the HTML template engine can render it. // It is exported so that the HTML template engine can render it.
type HTMLRow struct { type HTMLRow struct {
Domain string Domain string
Host string Host string
Provider string Provider string
IPMethod string IPMethod string
Status string Status string
IP string // current set ip CurrentIP string
IPs []string // previous ips PreviousIPs string
}
// ToHTML converts all the update record configs to HTML data ready to be templated
func ToHTML(recordsConfigs []RecordConfigType) (htmlData HTMLData) {
for i := range recordsConfigs {
htmlData.Rows = append(htmlData.Rows, recordsConfigs[i].toHTML())
}
return htmlData
} }

View File

@@ -1,25 +0,0 @@
package models
import "fmt"
// IPMethodType is the enum type for all the possible IP methods
type IPMethodType string
// All possible IP methods values
const (
IPMETHODPROVIDER IPMethodType = "provider"
IPMETHODGOOGLE = "google"
IPMETHODOPENDNS = "opendns"
)
// ParseIPMethod obtains the IP method from a string
func ParseIPMethod(s string) (IPMethodType, error) {
switch s {
case "provider", "google", "opendns":
return IPMethodType(s), nil
case "duckduckgo":
return "", fmt.Errorf("IP method duckduckgo no longer supported")
default:
return "", fmt.Errorf("IP method %s not recognized", s)
}
}

View File

@@ -1,62 +0,0 @@
package models
import (
"fmt"
)
// ProviderType is the enum type for the possible providers
type ProviderType uint8
// All possible provider values
const (
PROVIDERGODADDY ProviderType = iota
PROVIDERNAMECHEAP
PROVIDERDUCKDNS
PROVIDERDREAMHOST
PROVIDERCLOUDFLARE
PROVIDERNOIP
PROVIDERDNSPOD
)
func (provider ProviderType) String() string {
switch provider {
case PROVIDERGODADDY:
return "godaddy"
case PROVIDERNAMECHEAP:
return "namecheap"
case PROVIDERDUCKDNS:
return "duckdns"
case PROVIDERDREAMHOST:
return "dreamhost"
case PROVIDERCLOUDFLARE:
return "cloudflare"
case PROVIDERNOIP:
return "noip"
case PROVIDERDNSPOD:
return "dnspod"
default:
return "unknown"
}
}
// ParseProvider obtains the provider from a string
func ParseProvider(s string) (ProviderType, error) {
switch s {
case "godaddy":
return PROVIDERGODADDY, nil
case "namecheap":
return PROVIDERNAMECHEAP, nil
case "duckdns":
return PROVIDERDUCKDNS, nil
case "dreamhost":
return PROVIDERDREAMHOST, nil
case "cloudflare":
return PROVIDERCLOUDFLARE, nil
case "noip":
return PROVIDERNOIP, nil
case "dnspod":
return PROVIDERDNSPOD, nil
default:
return 0, fmt.Errorf("Provider %s not recognized", s)
}
}

35
internal/models/record.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import (
"fmt"
"net"
"time"
)
// Record contains all the information to update and display a DNS record
type Record struct { // internal
Settings Settings // fixed
History History // past information
Status Status
Message string
Time time.Time
}
// NewRecord returns a new Record with settings and some history
func NewRecord(settings Settings, IPs []net.IP, successTime time.Time) Record {
return Record{
Settings: settings,
History: History{
IPs: IPs,
SuccessTime: successTime,
},
}
}
func (r *Record) String() string {
status := string(r.Status)
if len(r.Message) > 0 {
status += " (" + r.Message + ")"
}
return fmt.Sprintf("%s: %s %s; %s", r.Settings.String(), status, r.Time.Format("2006-01-02 15:04:05 MST"), r.History.String())
}

View File

@@ -1,57 +0,0 @@
package models
import (
"sync"
"time"
)
// RecordConfigType contains all the information to update and display a DNS record
type RecordConfigType struct { // internal
Settings SettingsType // fixed
Status statusType // changes for each update
History historyType // past information
IsUpdating sync.Mutex // just to wait for an update to finish on chQuit signaling
}
// NewRecordConfig returns a new recordConfig with settings
func NewRecordConfig(settings SettingsType, ips []string, tSuccess time.Time) RecordConfigType {
return RecordConfigType{
Settings: settings,
History: newHistory(ips, tSuccess),
}
}
func (conf *RecordConfigType) String() string {
return conf.Settings.String() + ": " + conf.Status.String() + "; " + conf.History.String()
}
func (conf *RecordConfigType) toHTML() HTMLRow {
row := HTMLRow{
Domain: conf.Settings.getHTMLDomain(),
Host: conf.Settings.Host,
Provider: conf.Settings.getHTMLProvider(),
IPMethod: conf.Settings.getHTMLIPMethod(),
}
if conf.Status.code == UPTODATE {
conf.Status.message = "No IP change for " + conf.History.GetTSuccessDuration()
}
row.Status = conf.Status.toHTML()
ips := conf.History.GetIPs()
latestIP := ips[0]
if len(ips) > 0 {
row.IP = "<a href=\"https://ipinfo.io/" + latestIP + "\">" + latestIP + "</a>"
} else {
row.IP = "N/A"
}
if len(ips) > 1 {
for i, historicIP := range ips[1:] {
if i != len(ips[1:])-1 { // not the last one
historicIP += ", "
}
row.IPs = append(row.IPs, historicIP)
}
} else {
row.IPs = []string{"N/A"}
}
return row
}

View File

@@ -2,42 +2,15 @@ package models
import ( import (
"encoding/json" "encoding/json"
"fmt"
"regexp"
"time" "time"
"github.com/qdm12/golibs/verification"
) )
const ( // Settings contains the elements to update the DNS record
regexGoDaddyKey = `[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}` type Settings struct {
regexGodaddySecret = `[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}`
regexNamecheapPassword = `[a-f0-9]{32}`
regexDreamhostKey = `[a-zA-Z0-9]{16}`
regexCloudflareKey = `[a-zA-Z0-9]+`
regexCloudflareToken = `[a-zA-Z0-9_]{40}`
regexCloudflareUserServiceKey = `v1\.0.+`
)
// Regex MatchString functions
var (
matchGodaddyKey = regexp.MustCompile("^" + regexGoDaddyKey + "$").MatchString
matchGodaddySecret = regexp.MustCompile("^" + regexGodaddySecret + "$").MatchString
matchDuckDNSToken = regexp.MustCompile("^" + regexDuckDNSToken + "$").MatchString
matchNamecheapPassword = regexp.MustCompile("^" + regexNamecheapPassword + "$").MatchString
matchDreamhostKey = regexp.MustCompile("^" + regexDreamhostKey + "$").MatchString
matchCloudflareKey = regexp.MustCompile("^" + regexCloudflareKey + "$").MatchString
matchCloudflareToken = regexp.MustCompile("^" + regexCloudflareToken + "$").MatchString
matchCloudflareUserServiceKey = regexp.MustCompile("^" + regexCloudflareUserServiceKey + "$").MatchString
)
// SettingsType contains the elements to update the DNS record
type SettingsType struct {
Domain string Domain string
Host string Host string
Provider ProviderType Provider Provider
IPmethod IPMethodType IPMethod IPMethod
Delay time.Duration Delay time.Duration
NoDNSLookup bool NoDNSLookup bool
// Provider dependent fields // Provider dependent fields
@@ -54,7 +27,7 @@ type SettingsType struct {
Username string // NoIP only Username string // NoIP only
} }
func (settings *SettingsType) String() string { func (settings *Settings) String() string {
b, _ := json.Marshal( b, _ := json.Marshal(
struct { struct {
Domain string `json:"domain"` Domain string `json:"domain"`
@@ -63,14 +36,14 @@ func (settings *SettingsType) String() string {
}{ }{
settings.Domain, settings.Domain,
settings.Host, settings.Host,
settings.Provider.String(), string(settings.Provider),
}, },
) )
return string(b) return string(b)
} }
// BuildDomainName builds the domain name from the domain and the host of the settings // BuildDomainName builds the domain name from the domain and the host of the settings
func (settings *SettingsType) BuildDomainName() string { func (settings *Settings) BuildDomainName() string {
if settings.Host == "@" { if settings.Host == "@" {
return settings.Domain return settings.Domain
} else if settings.Host == "*" { } else if settings.Host == "*" {
@@ -79,117 +52,3 @@ func (settings *SettingsType) BuildDomainName() string {
return settings.Host + "." + settings.Domain return settings.Host + "." + settings.Domain
} }
} }
func (settings *SettingsType) getHTMLDomain() string {
return "<a href=\"http://" + settings.BuildDomainName() + "\">" + settings.Domain + "</a>"
}
func (settings *SettingsType) getHTMLProvider() string {
switch settings.Provider {
case PROVIDERNAMECHEAP:
return "<a href=\"https://namecheap.com\">Namecheap</a>"
case PROVIDERGODADDY:
return "<a href=\"https://godaddy.com\">GoDaddy</a>"
case PROVIDERDUCKDNS:
return "<a href=\"https://duckdns.org\">DuckDNS</a>"
case PROVIDERDREAMHOST:
return "<a href=\"https://https://www.dreamhost.com/\">Dreamhost</a>"
default:
return settings.Provider.String()
}
}
// TODO map to icons
func (settings *SettingsType) getHTMLIPMethod() string {
switch settings.IPmethod {
case IPMETHODPROVIDER:
return settings.getHTMLProvider()
case IPMETHODGOOGLE:
return "<a href=\"https://google.com/search?q=ip\">Google</a>"
case IPMETHODOPENDNS:
return "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
default:
return string(settings.IPmethod)
}
}
// Verify verifies all the settings provided are valid
func (settings *SettingsType) Verify() error {
if !verification.MatchDomain(settings.Domain) {
return fmt.Errorf("invalid domain name format for settings %s", settings)
} else if len(settings.Host) == 0 {
return fmt.Errorf("host cannot be empty for settings %s", settings)
}
switch settings.Provider {
case PROVIDERNAMECHEAP:
if !matchNamecheapPassword(settings.Password) {
return fmt.Errorf("invalid password format for settings %s", settings)
}
case PROVIDERGODADDY:
if !matchGodaddyKey(settings.Key) {
return fmt.Errorf("invalid key format for settings %s", settings)
} else if !matchGodaddySecret(settings.Secret) {
return fmt.Errorf("invalid secret format for settings %s", settings)
} else if settings.IPmethod == IPMETHODPROVIDER {
return fmt.Errorf("unsupported IP update method for settings %s", settings)
}
case PROVIDERDUCKDNS:
if !matchDuckDNSToken(settings.Token) {
return fmt.Errorf("invalid token format for settings %s", settings)
} else if settings.Host != "@" {
return fmt.Errorf("host can only be \"@\" for settings %s", settings)
}
case PROVIDERDREAMHOST:
if !matchDreamhostKey(settings.Key) {
return fmt.Errorf("invalid key format for settings %s", settings)
} else if settings.Host != "@" {
return fmt.Errorf("host can only be \"@\" for settings %s", settings)
} else if settings.IPmethod == IPMETHODPROVIDER {
return fmt.Errorf("unsupported IP update method for settings %s", settings)
}
case PROVIDERCLOUDFLARE:
if settings.Key != "" { // email and key must be provided
if !matchCloudflareKey(settings.Key) {
return fmt.Errorf("invalid key format for settings %s", settings)
} else if !verification.MatchEmail(settings.Email) {
return fmt.Errorf("invalid email format for settings %s", settings)
}
} else if settings.UserServiceKey != "" { // only user service key
if !matchCloudflareUserServiceKey(settings.UserServiceKey) {
return fmt.Errorf("invalid user service key format for settings %s", settings)
}
} else { // otherwise use an API token
if !matchCloudflareToken(settings.Token) {
return fmt.Errorf("invalid API token key format for settings %s", settings)
}
}
if len(settings.ZoneIdentifier) == 0 {
return fmt.Errorf("zone identifier cannot be empty to settings %s", settings)
} else if len(settings.Identifier) == 0 {
return fmt.Errorf("identifier cannot be empty to settings %s", settings)
} else if settings.IPmethod == IPMETHODPROVIDER {
return fmt.Errorf("unsupported IP update method for settings %s", settings)
} else if settings.Ttl == 0 {
return fmt.Errorf("TTL cannot be left to 0 for settings %s", settings)
}
case PROVIDERNOIP:
if len(settings.Username) == 0 {
return fmt.Errorf("username cannot be empty for settings %s", settings)
} else if len(settings.Username) > 50 {
return fmt.Errorf("username cannot be longer than 50 characters for settings %s", settings)
} else if len(settings.Password) == 0 {
return fmt.Errorf("password cannot be empty for settings %s", settings)
} else if settings.Host == "*" {
return fmt.Errorf("host cannot be * for settings %s", settings)
}
case PROVIDERDNSPOD:
if len(settings.Token) == 0 {
return fmt.Errorf("token cannot be empty for settings %s", settings)
} else if settings.IPmethod == IPMETHODPROVIDER {
return fmt.Errorf("unsupported IP update method for settings %s", settings)
}
default:
return fmt.Errorf("provider \"%s\" is not supported", settings.Provider)
}
return nil
}

View File

@@ -1,107 +0,0 @@
package models
import (
"fmt"
"sync"
"time"
)
type statusCode uint8
// Update possible status codes: FAIL, SUCCESS, UPTODATE or UPDATING
const (
FAIL statusCode = iota
SUCCESS
UPTODATE
)
func (code *statusCode) String() (s string) {
switch *code {
case SUCCESS:
return "Success"
case FAIL:
return "Failure"
case UPTODATE:
return "Up to date"
default:
return "Unknown status code!"
}
}
func (code *statusCode) toHTML() (s string) {
switch *code {
case SUCCESS:
return `<font color="green">Success</font>`
case FAIL:
return `<font color="red">Failure</font>`
case UPTODATE:
return `<font color="#00CC66">Up to date</font>`
default:
return `<font color="red">Unknown status code!</font>`
}
}
type statusType struct {
code statusCode
message string
time time.Time
sync.RWMutex
}
func (status *statusType) SetTime(t time.Time) {
status.Lock()
defer status.Unlock()
status.time = t
}
func (status *statusType) SetCode(code statusCode) {
status.Lock()
defer status.Unlock()
status.code = code
}
func (status *statusType) SetMessage(format string, a ...interface{}) {
status.Lock()
defer status.Unlock()
status.message = fmt.Sprintf(format, a...)
}
func (status *statusType) GetTime() time.Time {
status.RLock()
defer status.RUnlock()
return status.time
}
func (status *statusType) GetCode() statusCode {
status.RLock()
defer status.RUnlock()
return status.code
}
func (status *statusType) GetMessage() string {
status.RLock()
defer status.RUnlock()
return status.message
}
func (status *statusType) String() (s string) {
status.RLock()
defer status.RUnlock()
s += status.code.String()
if status.message != "" {
s += " (" + status.message + ")"
}
s += " at " + status.time.Format("2006-01-02 15:04:05 MST")
return s
}
func (status *statusType) toHTML() (s string) {
status.RLock()
defer status.RUnlock()
s += status.code.toHTML()
if status.message != "" {
s += " (" + status.message + ")"
}
s += ", " + time.Since(status.time).Round(time.Second).String() + " ago"
return s
}

View File

@@ -2,23 +2,27 @@ package network
import ( import (
"fmt" "fmt"
"net"
"github.com/qdm12/golibs/network" "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification" "github.com/qdm12/golibs/verification"
) )
// GetPublicIP downloads a webpage and extracts the IP address from it // GetPublicIP downloads a webpage and extracts the IP address from it
func GetPublicIP(client network.Client, URL string) (ip string, err error) { func GetPublicIP(client network.Client, URL string) (ip net.IP, err error) {
content, status, err := client.GetContent(URL) content, status, err := client.GetContent(URL)
if err != nil { if err != nil {
return ip, fmt.Errorf("cannot get public IP address from %s: %s", URL, err) return nil, fmt.Errorf("cannot get public IP address from %s: %s", URL, err)
} else if status != 200 { } else if status != 200 {
return ip, fmt.Errorf("cannot get public IP address from %s: HTTP status code %d", URL, status) return nil, fmt.Errorf("cannot get public IP address from %s: HTTP status code %d", URL, status)
} }
ips := verification.SearchIPv4(string(content)) ips := verification.NewVerifier().SearchIPv4(string(content))
if ips == nil { if ips == nil {
return ip, fmt.Errorf("no public IP found at %s: %s", URL, err) return nil, fmt.Errorf("no public IP found at %s: %s", URL, err)
}
ip = net.ParseIP(ips[0])
if ip == nil {
return nil, fmt.Errorf("Public IP address %q found at %s is not valid", ips[0], URL)
} }
ip = ips[0]
return ip, nil return ip, nil
} }

View File

@@ -8,11 +8,11 @@ import (
// BuildHTTPPut is used for GoDaddy and Cloudflare only // 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) {
jsonData, err := json.Marshal(body) b, err := json.Marshal(body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
request, err = http.NewRequest(http.MethodPut, URL, bytes.NewBuffer(jsonData)) request, err = http.NewRequest(http.MethodPut, URL, bytes.NewBuffer(b))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,108 @@
package params
import (
"fmt"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
func (p *params) isConsistent(settings models.Settings) error {
switch {
case !ipMethodIsValid(settings.IPMethod, constants.IPMethodChoices()):
return fmt.Errorf("IP method %q is not recognized", settings.IPMethod)
case !p.verifier.MatchDomain(settings.Domain):
return fmt.Errorf("invalid domain name format")
case len(settings.Host) == 0:
return fmt.Errorf("host cannot be empty")
}
switch settings.Provider {
case constants.PROVIDERNAMECHEAP:
if !constants.MatchNamecheapPassword(settings.Password) {
return fmt.Errorf("invalid password format")
}
case constants.PROVIDERGODADDY:
switch {
case !constants.MatchGodaddyKey(settings.Key):
return fmt.Errorf("invalid key format")
case !constants.MatchGodaddySecret(settings.Secret):
return fmt.Errorf("invalid secret format")
case settings.IPMethod == constants.IPMETHODPROVIDER:
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
}
case constants.PROVIDERDUCKDNS:
switch {
case !constants.MatchDuckDNSToken(settings.Token):
return fmt.Errorf("invalid token format")
case settings.Host != "@":
return fmt.Errorf(`host can only be "@"`)
}
case constants.PROVIDERDREAMHOST:
switch {
case !constants.MatchDreamhostKey(settings.Key):
return fmt.Errorf("invalid key format")
case settings.Host != "@":
return fmt.Errorf(`host can only be "@"`)
case settings.IPMethod == constants.IPMETHODPROVIDER:
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
}
case constants.PROVIDERCLOUDFLARE:
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.IPMethod == constants.IPMETHODPROVIDER:
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
case settings.Ttl == 0:
return fmt.Errorf("TTL cannot be left to 0")
}
case constants.PROVIDERNOIP:
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 "*"`)
}
case constants.PROVIDERDNSPOD:
switch {
case len(settings.Token) == 0:
return fmt.Errorf("token cannot be empty")
case settings.IPMethod == constants.IPMETHODPROVIDER:
return fmt.Errorf("unsupported IP update method")
}
default:
return fmt.Errorf("provider %q is not supported", settings.Provider)
}
return nil
}
func ipMethodIsValid(ipMethod models.IPMethod, possibilities []models.IPMethod) bool {
for i := range possibilities {
if ipMethod == possibilities[i] {
return true
}
}
return false
}

View File

@@ -3,75 +3,55 @@ package params
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"os"
"time" "time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models" "github.com/qdm12/ddns-updater/internal/models"
) )
type configType struct {
Settings []settingsType `json:"settings"`
}
type settingsType struct { type settingsType struct {
Provider string `json:"provider"` Provider string `json:"provider"`
Domain string `json:"domain"` Domain string `json:"domain"`
IPMethod string `json:"ip_method"` IPMethod string `json:"ip_method"`
Delay time.Duration `json:"delay"` Delay uint64 `json:"delay"`
NoDNSLookup bool `json:"no_dns_lookup"` NoDNSLookup bool `json:"no_dns_lookup"`
Host string `json:"host"` Host string `json:"host"`
Password string `json:"password"` // Namecheap, NoIP only Password string `json:"password"` // Namecheap, NoIP only
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
Secret string `json:"secret"` // GoDaddy only Secret string `json:"secret"` // GoDaddy only
Token string `json:"token"` // DuckDNS only Token string `json:"token"` // DuckDNS and Cloudflare only
Email string `json:"email"` // Cloudflare only Email string `json:"email"` // Cloudflare only
Username string `json:"username"` // NoIP only Username string `json:"username"` // NoIP only
UserServiceKey string `json:"user_service_key"` // Cloudflare only UserServiceKey string `json:"user_service_key"` // Cloudflare only
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
Identifier string `json:"identifier"` // Cloudflare only Identifier string `json:"identifier"` // Cloudflare only
Proxied bool `json:"proxied"` // Cloudflare only Proxied bool `json:"proxied"` // Cloudflare only
Ttl uint `json:"ttl"` // Cloudflare only Ttl uint `json:"ttl"` // Cloudflare only
} }
// GetSettings obtain the update settings from config.json // GetSettings obtain the update settings from config.json
func GetSettings(filePath string) (settings []models.SettingsType, warnings []string, err error) { func (p *params) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
f, err := os.Open(filePath) bytes, err := p.readFile(filePath)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
bytes, err := ioutil.ReadAll(f) var config struct {
f.Close() Settings []settingsType `json:"settings"`
if err != nil {
return nil, nil, err
} }
var config configType if err := json.Unmarshal(bytes, &config); err != nil {
err = json.Unmarshal(bytes, &config)
if err != nil {
return nil, nil, err return nil, nil, err
} }
for _, s := range config.Settings { for _, s := range config.Settings {
provider, err := models.ParseProvider(s.Provider) switch models.Provider(s.Provider) {
if err != nil { case constants.PROVIDERDREAMHOST, constants.PROVIDERDUCKDNS:
warnings = append(warnings, err.Error()) s.Host = "@" // only choice available
continue
} }
IPMethod, err := models.ParseIPMethod(s.IPMethod) setting := models.Settings{
if err != nil { Provider: models.Provider(s.Provider),
warnings = append(warnings, err.Error())
continue
}
delay := time.Second * s.Delay
host := s.Host
if provider == models.PROVIDERDREAMHOST || provider == models.PROVIDERDUCKDNS {
host = "@" // only one choice
}
setting := models.SettingsType{
Provider: provider,
Domain: s.Domain, Domain: s.Domain,
Host: host, Host: s.Host,
IPmethod: IPMethod, IPMethod: models.IPMethod(s.IPMethod),
Delay: delay, Delay: time.Second * time.Duration(s.Delay),
NoDNSLookup: s.NoDNSLookup, NoDNSLookup: s.NoDNSLookup,
Password: s.Password, Password: s.Password,
Key: s.Key, Key: s.Key,
@@ -85,8 +65,8 @@ func GetSettings(filePath string) (settings []models.SettingsType, warnings []st
Proxied: s.Proxied, Proxied: s.Proxied,
Ttl: s.Ttl, Ttl: s.Ttl,
} }
if err := setting.Verify(); err != nil { if err := p.isConsistent(setting); err != nil {
warnings = append(warnings, err.Error()) warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue continue
} }
settings = append(settings, setting) settings = append(settings, setting)

View File

@@ -1,11 +1,85 @@
package params package params
import ( import (
"io/ioutil"
"net/url"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
libparams "github.com/qdm12/golibs/params" 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)
}
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 // GetDataDir obtains the data directory from the environment
// variable DATADIR // variable DATADIR
func GetDataDir(e libparams.EnvParams, dir string) (string, error) { func (p *params) GetDataDir(currentDir string) (string, error) {
return e.GetEnv("DATADIR", libparams.Default(dir+"/data")) 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"))
} }

View File

@@ -0,0 +1,18 @@
package persistence
import (
"net"
"time"
"github.com/qdm12/ddns-updater/internal/persistence/sqlite"
)
type Database interface {
Close() error
StoreNewIP(domain, host string, ip net.IP) (err error)
GetIPs(domain, host string) (ips []net.IP, tNew time.Time, err error)
}
func NewSQLite(dataDir string) (Database, error) {
return sqlite.NewDatabase(dataDir)
}

View File

@@ -0,0 +1,34 @@
package sqlite
import (
"database/sql"
"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
}

View File

@@ -0,0 +1,74 @@
package sqlite
import (
"net"
"time"
)
/* 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) (err error) {
// Disable the current IP
db.Lock()
defer db.Unlock()
_, err = db.sqlite.Exec(
`UPDATE updates_ips
SET current = 0
WHERE domain = ? AND host = ? AND current = 1`,
domain,
host,
)
if err != nil {
return err
}
// Inserts new IP
_, err = db.sqlite.Exec(
`INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current)
VALUES(?, ?, ?, ?, ?, ?);`,
domain,
host,
ip.String(),
time.Now(),
time.Now(), // unneeded but it's hard to modify tables in sqlite
1,
)
return err
}
// GetIPs gets all the IP addresses history for a certain domain and host, in the order
// from oldest to newest
func (db *database) GetIPs(domain, host string) (ips []net.IP, tNew time.Time, 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, tNew, err
}
defer func() {
err = rows.Close()
}()
for rows.Next() {
var ip string
var t time.Time
if err := rows.Scan(&ip, &t); err != nil {
return nil, tNew, err
}
if tNew.IsZero() {
tNew = t
}
ips = append(ips, net.ParseIP(ip))
}
if tNew.IsZero() {
tNew = time.Now()
}
return ips, tNew, rows.Err()
}

View File

@@ -1,39 +0,0 @@
package router
import (
"fmt"
"net"
"github.com/qdm12/ddns-updater/internal/models"
)
func IsHealthy(recordsConfigs []models.RecordConfigType) error {
for i := range recordsConfigs {
if recordsConfigs[i].Status.GetCode() == models.FAIL {
return fmt.Errorf("%s", recordsConfigs[i].String())
}
if recordsConfigs[i].Settings.NoDNSLookup {
continue
}
lookupIPs, err := net.LookupIP(recordsConfigs[i].Settings.BuildDomainName())
if err != nil {
return err
}
historyIPs := recordsConfigs[i].History.GetIPs()
if len(historyIPs) == 0 {
return fmt.Errorf("no set IP address found")
}
latestIP := historyIPs[0]
for _, lookupIP := range lookupIPs {
if lookupIP.String() != latestIP {
return fmt.Errorf(
"lookup IP address of %s is %s, not %s",
recordsConfigs[i].Settings.BuildDomainName(),
lookupIP,
latestIP,
)
}
}
}
return nil
}

View File

@@ -1,57 +0,0 @@
package router
import (
"fmt"
"net/http"
"text/template"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/admin"
"github.com/qdm12/golibs/logging"
"github.com/julienschmidt/httprouter"
)
type indexParamsType struct {
dir string
recordsConfigs []models.RecordConfigType
}
type updateParamsType struct {
rootURL string
forceCh chan struct{}
}
// CreateRouter returns a router with all the necessary routes configured
func CreateRouter(rootURL, dir string, forceCh chan struct{}, recordsConfigs []models.RecordConfigType, gotify admin.Gotify) *httprouter.Router {
indexParams := indexParamsType{
dir: dir,
recordsConfigs: recordsConfigs,
}
updateParams := updateParamsType{
rootURL: rootURL,
forceCh: forceCh,
}
router := httprouter.New()
router.GET(rootURL+"/", indexParams.get)
router.GET(rootURL+"/update", updateParams.get)
return router
}
func (params *indexParamsType) get(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
// TODO: Forms to change existing updates or add some
t := template.Must(template.ParseFiles(params.dir + "/ui/index.html"))
htmlData := models.ToHTML(params.recordsConfigs)
err := t.ExecuteTemplate(w, "index.html", htmlData) // TODO Without pointer?
if err != nil {
logging.Warn(err.Error())
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "An error occurred creating this webpage")
}
}
func (params *updateParamsType) get(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
params.forceCh <- struct{}{}
logging.Info("Update started manually")
http.Redirect(w, r, params.rootURL, 301)
}

View File

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

View File

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

99
internal/update/dnspod.go Normal file
View File

@@ -0,0 +1,99 @@
package update
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
libnetwork "github.com/qdm12/golibs/network"
)
func updateDNSPod(client libnetwork.Client, domain, host, token string, ip net.IP) (err error) {
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)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err := client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var recordResp struct {
Records []struct {
ID string `json:"id"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Line string `json:"line"`
} `json:"records"`
}
if err := json.Unmarshal(content, &recordResp); err != nil {
return err
}
var recordID, recordLine string
for _, record := range recordResp.Records {
if record.Type == "A" && record.Name == host {
receivedIP := net.ParseIP(record.Value)
if ip.Equal(receivedIP) {
return nil
}
recordID = record.ID
recordLine = record.Line
break
}
}
if len(recordID) == 0 {
return fmt.Errorf("record not found")
}
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)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err = client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var ddnsResp struct {
Record struct {
ID int64 `json:"id"`
Value string `json:"value"`
Name string `json:"name"`
} `json:"record"`
}
if err := json.Unmarshal(content, &ddnsResp); err != nil {
return err
}
receivedIP := net.ParseIP(ddnsResp.Record.Value)
if !ip.Equal(receivedIP) {
return fmt.Errorf("ip not set")
}
return nil
}

View File

@@ -0,0 +1,101 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/qdm12/ddns-updater/internal/constants"
libnetwork "github.com/qdm12/golibs/network"
)
func updateDreamhost(client libnetwork.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 := strings.ToLower(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 {
Result string `json:"result"`
Data []struct {
Editable string `json:"editable"`
Type string `json:"type"`
Record string `json:"record"`
Value string `json:"value"`
} `json:"data"`
}
if err := json.Unmarshal(content, &dhList); err != nil {
return err
} else if dhList.Result != "success" {
return fmt.Errorf(dhList.Result)
}
var oldIP net.IP
for _, data := range dhList.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
url = strings.ToLower(constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + domain + "&type=A&value=" + oldIP.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
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 = strings.ToLower(constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + 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
}

View File

@@ -0,0 +1,52 @@
package update
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
libnetwork "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
func updateDuckDNS(client libnetwork.Client, domain, token string, ip net.IP) (newIP net.IP, err error) {
url := strings.ToLower(constants.DuckdnsURL + "?domains=" + domain + "&token=" + token + "&verbose=true")
if ip != nil {
url += "&ip=" + ip.String()
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
s := string(content)
switch {
case len(s) < 2:
return nil, fmt.Errorf("response %q is too short", s)
case s[0:2] == "KO":
return nil, fmt.Errorf("invalid domain token combination")
case s[0:2] == "OK":
ips := verification.NewVerifier().SearchIPv4(s)
if ips == nil {
return nil, fmt.Errorf("no IP address in response")
}
newIP = net.ParseIP(ips[0])
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
}
if ip != nil && !newIP.Equal(ip) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
default:
return nil, fmt.Errorf("invalid response %q", s)
}
}

View File

@@ -0,0 +1,51 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
URL := constants.GodaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
r, err := network.BuildHTTPPut(
URL,
[]goDaddyPutBody{
goDaddyPutBody{
ip.String(),
},
},
)
if err != nil {
return err
}
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
}
if status != http.StatusOK {
var parsedJSON struct {
Message string `json:"message"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return err
} else if len(parsedJSON.Message) > 0 {
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
}
return fmt.Errorf("HTTP status %d", status)
}
return nil
}

View File

@@ -0,0 +1,49 @@
package update
import (
"encoding/xml"
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
libnetwork "github.com/qdm12/golibs/network"
)
func updateNamecheap(client libnetwork.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
url := strings.ToLower(constants.NamecheapURL + "?host=" + host + "&domain=" + domain + "&password=" + password)
if ip != nil {
url += "&ip=" + ip.String()
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedXML struct {
Errors struct {
Error string `xml:"Err1"`
} `xml:"errors"`
IP string `xml:"IP"`
}
err = xml.Unmarshal(content, &parsedXML)
if err != nil {
return nil, err
} else if parsedXML.Errors.Error != "" {
return nil, fmt.Errorf(parsedXML.Errors.Error)
}
newIP = net.ParseIP(parsedXML.IP)
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

61
internal/update/noip.go Normal file
View File

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

View File

@@ -1,87 +0,0 @@
package update
import (
"time"
"github.com/qdm12/ddns-updater/internal/database"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/admin"
libnetwork "github.com/qdm12/golibs/network"
)
// TriggerServer runs an infinite asynchronous periodic function that triggers updates
func TriggerServer(
delay time.Duration,
chForce, chQuit chan struct{}, // listener only
recordsConfigs []models.RecordConfigType, // does not change size so no pointer needed
client libnetwork.Client,
db database.SQL,
gotify admin.Gotify,
) {
var chQuitArr, chForceArr []chan struct{}
defer func() {
for i := range chForceArr {
close(chForceArr[i])
}
for i := range chQuitArr {
close(chQuitArr[i])
}
close(chForce)
close(chQuit)
}()
for i := range recordsConfigs {
chForceArr = append(chForceArr, make(chan struct{}))
chQuitArr = append(chQuitArr, make(chan struct{}))
customDelay := recordsConfigs[i].Settings.Delay
if customDelay > 0 {
go periodicServer(&recordsConfigs[i], customDelay, client, db, chForceArr[i], chQuitArr[i], gotify)
} else {
go periodicServer(&recordsConfigs[i], delay, client, db, chForceArr[i], chQuitArr[i], gotify)
}
}
// fan out channel signals
for {
select {
case <-chForce:
for i := range chForceArr {
chForceArr[i] <- struct{}{}
}
case <-chQuit:
for i := range chQuitArr {
chQuitArr[i] <- struct{}{}
}
return
}
}
}
func periodicServer(
recordConfig *models.RecordConfigType,
delay time.Duration,
client libnetwork.Client,
db database.SQL,
chForce, chQuit chan struct{},
gotify admin.Gotify,
) {
ticker := time.NewTicker(delay)
defer func() {
ticker.Stop()
close(chForce)
close(chQuit)
}()
for {
select {
case <-ticker.C:
go update(recordConfig, client, db, gotify)
case <-chForce:
go update(recordConfig, client, db, gotify)
case <-chQuit:
recordConfig.IsUpdating.Lock() // wait for an eventual update to finish
ticker.Stop()
close(chForce)
close(chQuit)
recordConfig.IsUpdating.Unlock()
return
}
}
}

View File

@@ -1,579 +1,171 @@
package update package update
import ( import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt" "fmt"
"net/http" "net"
"net/url"
"strings"
"time" "time"
"github.com/qdm12/ddns-updater/internal/database"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
"github.com/qdm12/golibs/admin"
"github.com/qdm12/golibs/logging" "github.com/qdm12/golibs/logging"
libnetwork "github.com/qdm12/golibs/network" libnetwork "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification" "github.com/qdm12/golibs/verification"
"github.com/google/uuid" "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"
) )
const ( type Updater interface {
namecheapURL = "https://dynamicdns.park-your-domain.com/update" Update(id int) error
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"
)
func update( type updater struct {
recordConfig *models.RecordConfigType, db data.Database
client libnetwork.Client, logger logging.Logger
db database.SQL, client libnetwork.Client
gotify admin.Gotify, notify notifyFunc
) { verifier verification.Verifier
var err error }
recordConfig.IsUpdating.Lock()
defer recordConfig.IsUpdating.Unlock()
recordConfig.Status.SetTime(time.Now())
// Get the public IP address type notifyFunc func(priority int, messageArgs ...interface{})
ip, err := getPublicIP(client, recordConfig.Settings.IPmethod)
if err != nil { func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
recordConfig.Status.SetCode(models.FAIL) return &updater{
recordConfig.Status.SetMessage("%s", err) db: db,
logging.Warn(recordConfig.String()) logger: logger,
if gotify != nil { client: client,
gotify.Notify("DDNS Updater", 5, recordConfig.String()) notify: notify,
} verifier: verification.NewVerifier(),
return
} }
// Note: empty IP means DNS provider provided }
ips := recordConfig.History.GetIPs()
if ip != "" && len(ips) > 0 && ip == ips[0] { // same IP as before func (u *updater) Update(id int) error {
recordConfig.Status.SetCode(models.UPTODATE) record, err := u.db.Select(id)
recordConfig.Status.SetMessage("No IP change for %s", recordConfig.History.GetTSuccessDuration()) if err != nil {
return 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())
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.SuccessTime = time.Now()
record.History.IPs = append(record.History.IPs, newIP)
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 := getPublicIP(u.client, settings.IPMethod) // 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 // Update the record
switch recordConfig.Settings.Provider { switch settings.Provider {
case models.PROVIDERNAMECHEAP: case constants.PROVIDERNAMECHEAP:
ip, err = updateNamecheap( ip, err = updateNamecheap(
client, u.client,
recordConfig.Settings.Host, settings.Host,
recordConfig.Settings.Domain, settings.Domain,
recordConfig.Settings.Password, settings.Password,
ip, ip,
) )
case models.PROVIDERGODADDY: case constants.PROVIDERGODADDY:
err = updateGoDaddy( err = updateGoDaddy(
client, u.client,
recordConfig.Settings.Host, settings.Host,
recordConfig.Settings.Domain, settings.Domain,
recordConfig.Settings.Key, settings.Key,
recordConfig.Settings.Secret, settings.Secret,
ip, ip,
) )
case models.PROVIDERDUCKDNS: case constants.PROVIDERDUCKDNS:
ip, err = updateDuckDNS( ip, err = updateDuckDNS(
client, u.client,
recordConfig.Settings.Domain, settings.Domain,
recordConfig.Settings.Token, settings.Token,
ip, ip,
) )
case models.PROVIDERDREAMHOST: case constants.PROVIDERDREAMHOST:
err = updateDreamhost( err = updateDreamhost(
client, u.client,
recordConfig.Settings.Domain, settings.Domain,
recordConfig.Settings.Key, settings.Key,
settings.BuildDomainName(),
ip, ip,
recordConfig.Settings.BuildDomainName(),
) )
case models.PROVIDERCLOUDFLARE: case constants.PROVIDERCLOUDFLARE:
err = updateCloudflare( err = updateCloudflare(
client, u.client,
recordConfig.Settings.ZoneIdentifier, settings.ZoneIdentifier,
recordConfig.Settings.Identifier, settings.Identifier,
recordConfig.Settings.Host, settings.Host,
recordConfig.Settings.Email, settings.Email,
recordConfig.Settings.Key, settings.Key,
recordConfig.Settings.UserServiceKey, settings.UserServiceKey,
recordConfig.Settings.Token, settings.Token,
settings.Proxied,
settings.Ttl,
ip, ip,
recordConfig.Settings.Proxied,
recordConfig.Settings.Ttl,
) )
case models.PROVIDERNOIP: case constants.PROVIDERNOIP:
ip, err = updateNoIP( ip, err = updateNoIP(
client, u.client,
recordConfig.Settings.BuildDomainName(), settings.BuildDomainName(),
recordConfig.Settings.Username, settings.Username,
recordConfig.Settings.Password, settings.Password,
ip, ip,
) )
case models.PROVIDERDNSPOD: case constants.PROVIDERDNSPOD:
err = updateDNSPod( err = updateDNSPod(
client, u.client,
recordConfig.Settings.Domain, settings.Domain,
recordConfig.Settings.Host, settings.Host,
recordConfig.Settings.Token, settings.Token,
ip, ip,
) )
default: default:
err = fmt.Errorf("unsupported provider \"%s\"", recordConfig.Settings.Provider) err = fmt.Errorf("provider %q is not supported", settings.Provider)
} }
if err != nil { if err != nil {
recordConfig.Status.SetCode(models.FAIL) return constants.FAIL, "", nil, err
recordConfig.Status.SetMessage("%s", err)
logging.Warn(recordConfig.String())
if gotify != nil {
gotify.Notify("DDNS Updater", 5, recordConfig.String())
}
return
} }
if len(ips) > 0 && ip == ips[0] { // same IP if ip != nil && ip.Equal(currentIP) {
recordConfig.Status.SetCode(models.UPTODATE) return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
recordConfig.Status.SetMessage("No IP change for %s", recordConfig.History.GetTSuccessDuration())
err = db.UpdateIPTime(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip)
if err != nil {
recordConfig.Status.SetCode(models.FAIL)
recordConfig.Status.SetMessage("Cannot update database: %s", err)
if gotify != nil {
gotify.Notify("DDNS Updater", 4, "Cannot update database: %s", err)
}
}
return
}
// new IP
recordConfig.Status.SetCode(models.SUCCESS)
recordConfig.Status.SetMessage("")
recordConfig.History.SetTSuccess(time.Now())
recordConfig.History.PrependIP(ip)
if gotify != nil {
if len(ips) == 0 {
gotify.Notify("DDNS Updater", 1, "%s has now IP address %s", recordConfig.Settings.BuildDomainName(), ip)
} else {
gotify.Notify("DDNS Updater", 1, "%s changed from %s to %s", recordConfig.Settings.BuildDomainName(), ips[0], ip)
}
}
err = db.StoreNewIP(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip)
if err != nil {
recordConfig.Status.SetCode(models.FAIL)
recordConfig.Status.SetMessage("Cannot update database: %s", err)
if gotify != nil {
gotify.Notify("DDNS Updater", 4, "Cannot update database: %s", err)
}
} }
return constants.SUCCESS, fmt.Sprintf("changed to %s", ip.String()), ip, nil
} }
func getPublicIP(client libnetwork.Client, IPmethod models.IPMethodType) (ip string, err error) { func getPublicIP(client libnetwork.Client, IPMethod models.IPMethod) (ip net.IP, err error) {
switch IPmethod { switch IPMethod {
case models.IPMETHODPROVIDER: case constants.IPMETHODPROVIDER:
return "", nil return nil, nil
case models.IPMETHODGOOGLE: case constants.IPMETHODGOOGLE:
return network.GetPublicIP(client, "https://google.com/search?q=ip") return network.GetPublicIP(client, "https://google.com/search?q=ip")
case models.IPMETHODOPENDNS: case constants.IPMETHODOPENDNS:
return network.GetPublicIP(client, "https://diagnostic.opendns.com/myip") return network.GetPublicIP(client, "https://diagnostic.opendns.com/myip")
} }
return "", fmt.Errorf("IP method %s is not supported", IPmethod) return nil, fmt.Errorf("IP method %q not supported", IPMethod)
}
func updateNamecheap(client libnetwork.Client, host, domain, password, ip string) (newIP string, err error) {
url := strings.ToLower(namecheapURL + "?host=" + host + "&domain=" + domain + "&password=" + password)
if len(ip) > 0 {
url += "&ip=" + ip
}
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 != 200 { // TODO test / combine with below
return "", fmt.Errorf("HTTP status %d", status)
}
var parsedXML struct {
Errors struct {
Error string `xml:"Err1"`
} `xml:"errors"`
IP string `xml:"IP"`
}
err = xml.Unmarshal(content, &parsedXML)
if err != nil {
return "", err
} else if parsedXML.Errors.Error != "" {
return "", fmt.Errorf(parsedXML.Errors.Error)
}
ips := verification.SearchIPv4(parsedXML.IP)
if ips == nil {
return "", fmt.Errorf("no IP address in response")
}
newIP = ips[0]
if len(ip) > 0 && ip != newIP {
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
}
return newIP, nil
}
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret, ip string) error {
if len(ip) == 0 {
return fmt.Errorf("invalid empty IP address")
}
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
URL := godaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
r, err := network.BuildHTTPPut(
URL,
[]goDaddyPutBody{
goDaddyPutBody{
ip,
},
},
)
if err != nil {
return err
}
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
}
if status != 200 {
var parsedJSON struct {
Message string `json:"message"`
}
err = json.Unmarshal(content, &parsedJSON)
if err != nil {
return err
} else if parsedJSON.Message != "" {
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
}
return fmt.Errorf("HTTP status %d", status)
}
return nil
}
func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host, email, key, userServiceKey, token, ip string, proxied bool, ttl uint) (err error) {
if len(ip) == 0 {
return fmt.Errorf("invalid empty IP address")
}
type cloudflarePutBody struct {
Type string `json:"type"` // forced to A
Name string `json:"name"` // DNS record name i.e. example.com
Content string `json:"content"` // ip address
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
Ttl uint `json:"ttl"`
}
URL := cloudflareURL + "/zones/" + zoneIdentifier + "/dns_records/" + identifier
r, err := network.BuildHTTPPut(
URL,
cloudflarePutBody{
Type: "A",
Name: host,
Content: ip,
Proxied: proxied,
Ttl: ttl,
},
)
if err != nil {
return err
}
if len(token) > 0 {
r.Header.Set("Authorization", "Bearer "+token)
} else if len(userServiceKey) > 0 {
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
} else if len(email) > 0 && len(key) > 0 {
r.Header.Set("X-Auth-Email", email)
r.Header.Set("X-Auth-Key", key)
} else {
return fmt.Errorf("email and key are both unset and no user service key was provided")
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
}
if status > 415 {
return fmt.Errorf("HTTP status %d", status)
}
var parsedJSON struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result struct {
Content string `json:"content"`
} `json:"result"`
}
err = json.Unmarshal(content, &parsedJSON)
newIP := parsedJSON.Result.Content
if err != nil {
return err
} else if !parsedJSON.Success {
var errStr string
for _, e := range parsedJSON.Errors {
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
}
return fmt.Errorf(errStr)
} else if newIP != ip {
return fmt.Errorf("new IP address %s is not %s", newIP, ip)
}
return nil
}
func updateDuckDNS(client libnetwork.Client, domain, token, ip string) (newIP string, err error) {
url := strings.ToLower(duckdnsURL + "?domains=" + domain + "&token=" + token + "&verbose=true")
if len(ip) > 0 {
url += "&ip=" + ip
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return "", err
}
if status != 200 {
return "", fmt.Errorf("HTTP status %d", status)
}
s := string(content)
if s[0:2] == "KO" {
return "", fmt.Errorf("invalid domain token combination")
} else if s[0:2] == "OK" {
ips := verification.SearchIPv4(s)
if ips == nil {
return "", fmt.Errorf("no IP address in response")
}
newIP = ips[0]
if len(ip) > 0 && newIP != ip {
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
}
return newIP, nil
}
return "", fmt.Errorf("response \"%s\"", s)
}
func updateDreamhost(client libnetwork.Client, domain, key, ip, domainName string) error {
type dreamhostReponse struct {
Result string `json:"result"`
Data string `json:"data"`
}
// List records
url := strings.ToLower(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
}
if status != 200 {
return fmt.Errorf("HTTP status %d", status)
}
var dhList struct {
Result string `json:"result"`
Data []struct {
Editable string `json:"editable"`
Type string `json:"type"`
Record string `json:"record"`
Value string `json:"value"`
} `json:"data"`
}
err = json.Unmarshal(content, &dhList)
if err != nil {
return err
} else if dhList.Result != "success" {
return fmt.Errorf(dhList.Result)
}
var found bool
var oldIP string
for _, data := range dhList.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
}
oldIP = data.Value
if oldIP == ip { // success, nothing to change
return nil
}
found = true
break
}
}
if found { // Found editable record with a different IP address, so remove it
url = strings.ToLower(dreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + domain + "&type=A&value=" + oldIP)
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
}
if status != 200 {
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" { // this should not happen
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
}
// Create the right record
url = strings.ToLower(dreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + domain + "&type=A&value=" + ip)
r, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
}
if status != 200 {
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
}
func updateNoIP(client libnetwork.Client, hostname, username, password, ip string) (newIP string, err error) {
url := strings.ToLower(noIPURL + "?hostname=" + hostname)
if len(ip) > 0 {
url += "&myip=" + ip
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
r.Header.Set("Authorization", "Basic "+username+":"+password)
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return "", err
}
s := string(content)
switch s {
case "":
return "", fmt.Errorf("HTTP status %d", status)
case "911":
return "", fmt.Errorf("NoIP's internal server error 911")
case "abuse":
return "", fmt.Errorf("username is banned due to abuse")
case "!donator":
return "", fmt.Errorf("user has not this extra feature")
case "badagent":
return "", fmt.Errorf("user agent is banned")
case "badauth":
return "", fmt.Errorf("invalid username password combination")
case "nohost":
return "", fmt.Errorf("hostname does not exist")
}
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
ips := verification.SearchIPv4(s)
if ips == nil {
return "", fmt.Errorf("no IP address in response")
}
newIP = ips[0]
if len(ip) > 0 && newIP != ip {
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
}
return newIP, nil
}
return "", fmt.Errorf("unknown response: %s", s)
}
func updateDNSPod(client libnetwork.Client, domain, host, token, ip string) (err error) {
postValues := url.Values{}
postValues.Set("login_token", token)
postValues.Set("format", "json")
postValues.Set("domain", domain)
postValues.Set("length", "200")
postValues.Set("sub_domain", host)
postValues.Set("record_type", "A")
req, err := http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.List", bytes.NewBufferString(postValues.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err := client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != 200 {
return fmt.Errorf("HTTP status %d", status)
}
var recordResp struct {
Records []*struct {
ID string `json:"id"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Line string `json:"line"`
} `json:"records"`
}
if err := json.Unmarshal(content, &recordResp); err != nil {
return err
}
var recordID, recordLine string
for _, record := range recordResp.Records {
if record.Type == "A" && record.Name == host {
if ip == record.Value {
return nil
}
recordID = record.ID
recordLine = record.Line
break
}
}
if recordID == "" {
return fmt.Errorf("record not found")
}
postValues = url.Values{}
postValues.Set("login_token", token)
postValues.Set("format", "json")
postValues.Set("domain", domain)
postValues.Set("record_id", recordID)
postValues.Set("value", ip)
postValues.Set("record_line", recordLine)
postValues.Set("sub_domain", host)
req, err = http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.Ddns", bytes.NewBufferString(postValues.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err = client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != 200 {
return fmt.Errorf("HTTP status %d", status)
}
var ddnsResp struct {
Record struct {
ID int64 `json:"id"`
Value string `json:"value"`
Name string `json:"name"`
} `json:"record"`
}
if err := json.Unmarshal(content, &ddnsResp); err != nil {
return err
} else if ddnsResp.Record.Value != ip {
return fmt.Errorf("ip not set")
}
return nil
} }

View File

@@ -1,81 +1,81 @@
<html> <html>
<head> <head>
<title>DDNS Updater</title> <title>DDNS Updater</title>
<style> <style>
table { table {
font-family: arial, sans-serif; font-family: arial, sans-serif;
font-size: 14px; font-size: 14px;
font-size: 1vw; font-size: 1vw;
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
} }
td, th { td,
border: 2px solid #9a9fa1; th {
text-align: center; border: 2px solid #9a9fa1;
padding: 1%; text-align: center;
max-width: 35%; padding: 1%;
transition: all 0.7s; max-width: 35%;
} transition: all 0.7s;
}
th { th {
background-color: #d8daf7; background-color: #d8daf7;
} }
tr:nth-child(odd) { tr:nth-child(odd) {
background-color: #e6f7ea; background-color: #e6f7ea;
} }
tr:nth-child(even) { tr:nth-child(even) {
background-color: #f3ebe3; background-color: #f3ebe3;
} }
tr { tr {
transition: all 0.7s; transition: all 0.7s;
} }
tr:hover { tr:hover {
background: #c1e2f0; background: #c1e2f0;
} }
a { a {
text-decoration: none; text-decoration: none;
} }
</style> </style>
</head> </head>
<body> <body>
<table> <table>
<tr> <tr>
<th>Domain</th> <th>Domain</th>
<th>Host</th> <th>Host</th>
<th>Provider</th> <th>Provider</th>
<th>IP method</th> <th>IP method</th>
<th>Update status</th> <th>Update status</th>
<th>Set IP</th> <th>Set IP</th>
<th>Previous IPs (achronologically)</th> <th>Previous IPs (achronologically)</th>
</tr> </tr>
{{range .Rows}} {{range .Rows}}
<tr> <tr>
<td>{{.Domain}}</td> <td>{{.Domain}}</td>
<td>{{.Host}}</td> <td>{{.Host}}</td>
<td>{{.Provider}}</td> <td>{{.Provider}}</td>
<td>{{.IPMethod}}</td> <td>{{.IPMethod}}</td>
<td>{{.Status}}</td> <td>{{.Status}}</td>
<td>{{.IP}}</td> <td>{{.CurrentIP}}</td>
<td> <td>{{.PreviousIPs}}</td>
{{range .IPs}} </tr>
{{.}}
{{end}} {{end}}
</td> </table>
</tr> <div>
{{end}} Made by <a href="https://qqq.ninja">Quentin McGaw</a>
</table> </div>
<div> <div>
Made by <a href="https://qqq.ninja">Quentin McGaw</a> <a href="https://github.com/qdm12/ddns-updater">github.com/qdm12/ddns-updater</a>
</div> </div>
<div>
<a href="https://github.com/qdm12/ddns-updater">github.com/qdm12/ddns-updater</a>
</div>
</body> </body>
</html> </html>