mirror of
https://github.com/qdm12/ddns-updater.git
synced 2026-03-31 06:24:00 -04:00
Refactoring (#63)
- Only calls DNS API(s) once the public IP address changes - Only one ip method per ip version (ipv4, ipv6, ipv4/v6) - Gets the ip address once every period for all records - More object oriented coding instead of functional - Support to update ipv4 and ipv6 records separately, for supported DNS providers
This commit is contained in:
2
.github/workflows/buildx-latest.yml
vendored
2
.github/workflows/buildx-latest.yml
vendored
@@ -22,8 +22,6 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Buildx setup
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
version: latest
|
||||
- name: Dockerhub login
|
||||
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
|
||||
- name: Run Buildx
|
||||
|
||||
4
.github/workflows/buildx-release.yml
vendored
4
.github/workflows/buildx-release.yml
vendored
@@ -20,10 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- id: buildx
|
||||
- name: Buildx setup
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
version: latest
|
||||
- name: Dockerhub login
|
||||
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
|
||||
- name: Run Buildx
|
||||
|
||||
@@ -46,4 +46,4 @@ run:
|
||||
- .github
|
||||
|
||||
service:
|
||||
golangci-lint-version: 1.26.x # use the fixed version to not introduce new linters unexpectedly
|
||||
golangci-lint-version: 1.27.x # use the fixed version to not introduce new linters unexpectedly
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -5,7 +5,7 @@ FROM alpine:${ALPINE_VERSION} AS alpine
|
||||
RUN apk --update add ca-certificates tzdata
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
|
||||
ARG GOLANGCI_LINT_VERSION=v1.26.0
|
||||
ARG GOLANGCI_LINT_VERSION=v1.27.0
|
||||
RUN apk --update add git
|
||||
ENV CGO_ENABLED=0
|
||||
RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s ${GOLANGCI_LINT_VERSION}
|
||||
@@ -39,16 +39,27 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
|
||||
USER 1000
|
||||
ENTRYPOINT ["/updater/app"]
|
||||
ENV DELAY=10m \
|
||||
ROOT_URL=/ \
|
||||
ENV \
|
||||
# Core
|
||||
PERIOD=5m \
|
||||
IP_METHOD=cycle \
|
||||
IPV4_METHOD=cycle \
|
||||
IPV6_METHOD=cycle \
|
||||
HTTP_TIMEOUT=10s \
|
||||
|
||||
# Web UI
|
||||
LISTENING_PORT=8000 \
|
||||
ROOT_URL=/ \
|
||||
|
||||
# Backup
|
||||
BACKUP_PERIOD=0 \
|
||||
BACKUP_DIRECTORY=/updater/data \
|
||||
|
||||
# Other
|
||||
LOG_ENCODING=console \
|
||||
LOG_LEVEL=info \
|
||||
NODE_ID=0 \
|
||||
HTTP_TIMEOUT=10s \
|
||||
NODE_ID=-1 \
|
||||
GOTIFY_URL= \
|
||||
GOTIFY_TOKEN= \
|
||||
BACKUP_PERIOD=0 \
|
||||
BACKUP_DIRECTORY=/updater/data
|
||||
GOTIFY_TOKEN=
|
||||
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
|
||||
COPY --chown=1000 ui/* /updater/ui/
|
||||
|
||||
69
README.md
69
README.md
@@ -22,7 +22,7 @@
|
||||
|
||||

|
||||
|
||||
- 12.3MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
|
||||
- 14MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
|
||||
- Persistence with a JSON file *updates.json* to store old IP addresses with change times for each record
|
||||
- Docker healthcheck verifying the DNS resolution of your domains
|
||||
- Highly configurable
|
||||
@@ -56,21 +56,17 @@
|
||||
"provider": "namecheap",
|
||||
"domain": "example.com",
|
||||
"host": "@",
|
||||
"ip_method": "provider",
|
||||
"delay": 86400,
|
||||
"password": "e5322165c1d74692bfa6d807100c0310"
|
||||
},
|
||||
{
|
||||
"provider": "duckdns",
|
||||
"domain": "example.duckdns.org",
|
||||
"ip_method": "provider",
|
||||
"token": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"provider": "godaddy",
|
||||
"domain": "example.org",
|
||||
"host": "subdomain",
|
||||
"ip_method": "duckduckgo",
|
||||
"key": "aaaaaaaaaaaaaaaa",
|
||||
"secret": "aaaaaaaaaaaaaaaa"
|
||||
}
|
||||
@@ -104,12 +100,10 @@ Start by having the following content in *config.json*:
|
||||
{
|
||||
"provider": "",
|
||||
"domain": "",
|
||||
"ip_method": "",
|
||||
},
|
||||
{
|
||||
"provider": "",
|
||||
"domain": "",
|
||||
"ip_method": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -121,23 +115,11 @@ For all record update configuration, you need the following:
|
||||
|
||||
- `"provider"` is the DNS provider and can be `"godaddy"`, `"namecheap"`, `"duckdns"`, `"dreamhost"`, `"cloudflare"`, `"noip"`, `"dnspod"` or `"ddnss"`
|
||||
- `"domain"`
|
||||
- `"ip_method"` is the method to obtain your public IP address and can be:
|
||||
- `"provider"` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap, Infomaniak and NoIP**), most reliable.
|
||||
- `"opendns"` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip) (reliable)
|
||||
- `"ifconfig"` using [https://ifconfig.io/ip](https://ifconfig.io/ip) (may be rate limited)
|
||||
- `"ipinfo"` using [https://ipinfo.io/ip](https://ipinfo.io/ip) (may be rate limited)
|
||||
- `"ipify"` using [https://api.ipify.org](https://api.ipify.org) (may be rate limited)
|
||||
- `"ipify6"` using [https://api6.ipify.org](https://api.ipify.org) for IPv6 only (may be rate limited)
|
||||
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
|
||||
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php) for IPv4 only
|
||||
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php) for IPv6 only
|
||||
- `"cycle"` to cycle between each external methods, in order to avoid being rate limited
|
||||
- You can also specify an HTTPS URL to obtain your public IP address (i.e. `"ip_method": "https://ipinfo.io/ip"`)
|
||||
|
||||
You can optionnally add the parameters:
|
||||
|
||||
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable value.
|
||||
- `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the periodic Docker healthcheck from running a DNS lookup on your domain.
|
||||
- `"provider_ip"` can be `true` or `false`. It is only available for the providers `ddnss`, `duckdns`, `infomaniak`, `namecheap` and `noip`. It allows to let your DNS provider to determine your IPv4 address (and/or IPv6 address) automatically when you send an update request, without sending the new IP address detected by the program in the request.
|
||||
|
||||
For each DNS provider exist some specific parameters you need to add, as described below:
|
||||
|
||||
@@ -188,30 +170,46 @@ Infomaniak:
|
||||
- `"user"`
|
||||
- `"password"`
|
||||
- `"host"` is your host and can be a subdomain or `"@"`
|
||||
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
|
||||
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
|
||||
|
||||
DDNSS.de:
|
||||
|
||||
- `"user"`
|
||||
- `"password"`
|
||||
- `"host"` is your host and can be a subdomain or `"@"`
|
||||
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
|
||||
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `DELAY` | `10m` | Default delay between updates, following [this format](https://golang.org/pkg/time/#ParseDuration) |
|
||||
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
|
||||
| `PERIOD` | `5m` | Default period of IP address check, following [this format](https://golang.org/pkg/time/#ParseDuration) |
|
||||
| `IP_METHOD` | `cycle` | Method to obtain the public IP address (ipv4 or ipv6). Can be `cycle`, `opendns`, `ifconfig`, `ipinfo` or an https url |
|
||||
| `IPV4_METHOD` | `cycle` | Method to obtain the public IPv4 address only. Can be `cycle`, `ipify`, `ddnss4` or an https url |
|
||||
| `IPV6_METHOD` | `cycle` | Method to obtain the public IPv6 address only. Can be `cycle`, `ipify6`, `ddnss6` or an https url |
|
||||
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
|
||||
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
|
||||
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
|
||||
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
|
||||
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
|
||||
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
|
||||
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
|
||||
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
|
||||
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
|
||||
| `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
|
||||
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
|
||||
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
|
||||
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
|
||||
|
||||
The ip methods available are as follows:
|
||||
|
||||
- `cycle` cycles between all ip methods available for the specified ip version, if any. This allows you not to be blocked for making too many requests.
|
||||
- `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
|
||||
- `ifconfig` using [https://ifconfig.io/ip](https://ifconfig.io/ip)
|
||||
- `ipinfo` using [https://ipinfo.io/ip](https://ipinfo.io/ip)
|
||||
- `ipify` using [https://api.ipify.org](https://api.ipify.org)
|
||||
- `ipify6` using [https://api6.ipify.org](https://api.ipify.org)
|
||||
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
|
||||
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php)
|
||||
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php)
|
||||
- You can also specify an HTTPS URL to obtain your public IP address (i.e. `-e IP_METHOD=https://ipinfo.io/ip`)
|
||||
|
||||
### Host firewall
|
||||
|
||||
@@ -378,13 +376,12 @@ To set it up with DDNS updater:
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Update dependencies
|
||||
- [ ] Mockgen instead of mockery
|
||||
- [ ] Other types or records
|
||||
- [ ] icon.ico for webpage
|
||||
- [ ] Record events log
|
||||
- [ ] Hot reload of config.json
|
||||
- [ ] Unit tests
|
||||
- [ ] Move provider specific setup from readme to Wiki
|
||||
- [ ] ReactJS frontend
|
||||
- [ ] Live update of website
|
||||
- [ ] Change settings
|
||||
- Change settings
|
||||
- icon.ico for webpage
|
||||
- Live update periodically (websocket?)
|
||||
- [ ] Record events log
|
||||
- [ ] Other types or records: AAAA, C, MX
|
||||
- [ ] Unit tests
|
||||
- [ ] Hot reload of config.json
|
||||
|
||||
@@ -11,14 +11,6 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/golibs/admin"
|
||||
libhealthcheck "github.com/qdm12/golibs/healthcheck"
|
||||
"github.com/qdm12/golibs/logging"
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/network/connectivity"
|
||||
libparams "github.com/qdm12/golibs/params"
|
||||
"github.com/qdm12/golibs/server"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/backup"
|
||||
"github.com/qdm12/ddns-updater/internal/data"
|
||||
"github.com/qdm12/ddns-updater/internal/handlers"
|
||||
@@ -26,9 +18,15 @@ import (
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/params"
|
||||
"github.com/qdm12/ddns-updater/internal/persistence"
|
||||
recordslib "github.com/qdm12/ddns-updater/internal/records"
|
||||
"github.com/qdm12/ddns-updater/internal/splash"
|
||||
"github.com/qdm12/ddns-updater/internal/trigger"
|
||||
"github.com/qdm12/ddns-updater/internal/update"
|
||||
"github.com/qdm12/golibs/admin"
|
||||
libhealthcheck "github.com/qdm12/golibs/healthcheck"
|
||||
"github.com/qdm12/golibs/logging"
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/network/connectivity"
|
||||
"github.com/qdm12/golibs/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -37,6 +35,19 @@ func main() {
|
||||
// returns 2 on os signal
|
||||
}
|
||||
|
||||
type allParams struct {
|
||||
period time.Duration
|
||||
ipMethod models.IPMethod
|
||||
ipv4Method models.IPMethod
|
||||
ipv6Method models.IPMethod
|
||||
dir string
|
||||
dataDir string
|
||||
listeningPort string
|
||||
rootURL string
|
||||
backupPeriod time.Duration
|
||||
backupDirectory string
|
||||
}
|
||||
|
||||
func _main(ctx context.Context, timeNow func() time.Time) int {
|
||||
if libhealthcheck.Mode(os.Args) {
|
||||
// Running the program in a separate instance through the Docker
|
||||
@@ -66,20 +77,20 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, err := getParams(paramsReader)
|
||||
p, err := getParams(paramsReader, logger)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
notify(4, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
persistentDB, err := persistence.NewJSON(dataDir)
|
||||
persistentDB, err := persistence.NewJSON(p.dataDir)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
notify(4, err)
|
||||
return 1
|
||||
}
|
||||
settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
|
||||
settings, warnings, err := paramsReader.GetSettings(p.dataDir + "/config.json")
|
||||
for _, w := range warnings {
|
||||
logger.Warn(w)
|
||||
notify(2, w)
|
||||
@@ -92,28 +103,21 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
|
||||
if len(settings) > 1 {
|
||||
logger.Info("Found %d settings to update records", len(settings))
|
||||
} else if len(settings) == 1 {
|
||||
logger.Info("Found single setting to update records")
|
||||
logger.Info("Found single setting to update record")
|
||||
}
|
||||
for _, err := range connectivity.NewConnectivity(5 * time.Second).Checks("google.com") {
|
||||
logger.Warn(err)
|
||||
}
|
||||
records := make([]models.Record, len(settings))
|
||||
idToPeriod := make(map[int]time.Duration)
|
||||
i := 0
|
||||
for id, setting := range settings {
|
||||
logger.Info("Reading history from database: domain %s host %s", setting.Domain, setting.Host)
|
||||
events, err := persistentDB.GetEvents(setting.Domain, setting.Host)
|
||||
records := make([]recordslib.Record, len(settings))
|
||||
for i, s := range settings {
|
||||
logger.Info("Reading history from database: domain %s host %s", s.Domain(), s.Host())
|
||||
events, err := persistentDB.GetEvents(s.Domain(), s.Host())
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
notify(4, err)
|
||||
return 1
|
||||
}
|
||||
records[i] = models.NewRecord(setting, events)
|
||||
idToPeriod[id] = defaultPeriod
|
||||
if setting.Delay > 0 {
|
||||
idToPeriod[id] = setting.Delay
|
||||
}
|
||||
i++
|
||||
records[i] = recordslib.New(s, events)
|
||||
}
|
||||
HTTPTimeout, err := paramsReader.GetHTTPTimeout()
|
||||
if err != nil {
|
||||
@@ -130,30 +134,27 @@ func _main(ctx context.Context, timeNow func() time.Time) int {
|
||||
}
|
||||
}()
|
||||
updater := update.NewUpdater(db, logger, client, notify)
|
||||
ipGetter := update.NewIPGetter(client, p.ipMethod, p.ipv4Method, p.ipv6Method)
|
||||
runner := update.NewRunner(updater, ipGetter, logger, timeNow)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
checkError := func(err error) {
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
}
|
||||
}
|
||||
forceUpdate := trigger.StartUpdates(ctx, updater, idToPeriod, checkError)
|
||||
forceUpdate := runner.Run(ctx, p.period, records)
|
||||
forceUpdate()
|
||||
productionHandlerFunc := handlers.NewHandler(rootURL, dir, db, logger, forceUpdate, checkError).GetHandlerFunc()
|
||||
productionHandlerFunc := handlers.MakeHandler(p.rootURL, p.dir+"/ui", db, logger, forceUpdate, timeNow)
|
||||
healthcheckHandlerFunc := libhealthcheck.GetHandler(func() error {
|
||||
return healthcheck.IsHealthy(db, net.LookupIP, logger)
|
||||
})
|
||||
logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL)
|
||||
logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %q", p.listeningPort, p.rootURL)
|
||||
notify(1, fmt.Sprintf("Launched with %d records to watch", len(records)))
|
||||
serverErrors := make(chan []error)
|
||||
go func() {
|
||||
serverErrors <- server.RunServers(ctx,
|
||||
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionHandlerFunc},
|
||||
server.Settings{Name: "production", Addr: "0.0.0.0:" + p.listeningPort, Handler: productionHandlerFunc},
|
||||
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
|
||||
)
|
||||
}()
|
||||
|
||||
go backupRunLoop(ctx, backupPeriod, dir, backupDirectory, logger, timeNow)
|
||||
go backupRunLoop(ctx, p.backupPeriod, p.dir, p.backupDirectory, logger, timeNow)
|
||||
|
||||
osSignals := make(chan os.Signal, 1)
|
||||
signal.Notify(osSignals,
|
||||
@@ -207,42 +208,52 @@ func setupGotify(paramsReader params.Reader, logger logging.Logger) (notify func
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getParams(paramsReader params.Reader) (
|
||||
dir, dataDir,
|
||||
listeningPort, rootURL string,
|
||||
defaultPeriod time.Duration,
|
||||
backupPeriod time.Duration, backupDirectory string,
|
||||
err error) {
|
||||
dir, err = paramsReader.GetExeDir()
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
func getParams(paramsReader params.Reader, logger logging.Logger) (p allParams, err error) {
|
||||
var warnings []string
|
||||
p.period, warnings, err = paramsReader.GetPeriod()
|
||||
for _, warning := range warnings {
|
||||
logger.Warn(warning)
|
||||
}
|
||||
dataDir, err = paramsReader.GetDataDir(dir)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
listeningPort, _, err = paramsReader.GetListeningPort()
|
||||
p.ipMethod, err = paramsReader.GetIPMethod()
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
rootURL, err = paramsReader.GetRootURL()
|
||||
p.ipv4Method, err = paramsReader.GetIPv4Method()
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
defaultPeriod, err = paramsReader.GetDelay(libparams.Default("10m"))
|
||||
p.ipv6Method, err = paramsReader.GetIPv6Method()
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
|
||||
backupPeriod, err = paramsReader.GetBackupPeriod()
|
||||
p.dir, err = paramsReader.GetExeDir()
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
backupDirectory, err = paramsReader.GetBackupDirectory()
|
||||
p.dataDir, err = paramsReader.GetDataDir(p.dir)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, 0, "", err
|
||||
return p, err
|
||||
}
|
||||
return dir, dataDir, listeningPort, rootURL, defaultPeriod, backupPeriod, backupDirectory, nil
|
||||
p.listeningPort, _, err = paramsReader.GetListeningPort()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.rootURL, err = paramsReader.GetRootURL()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.backupPeriod, err = paramsReader.GetBackupPeriod()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.backupDirectory, err = paramsReader.GetBackupDirectory()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func backupRunLoop(ctx context.Context, backupPeriod time.Duration, exeDir, outputDir string,
|
||||
|
||||
@@ -4,28 +4,23 @@
|
||||
"provider": "namecheap",
|
||||
"domain": "example.com",
|
||||
"host": "@",
|
||||
"ip_method": "provider",
|
||||
"delay": 86400,
|
||||
"password": "e5322165c1d74692bfa6d807100c0310"
|
||||
},
|
||||
{
|
||||
"provider": "duckdns",
|
||||
"domain": "example.duckdns.org",
|
||||
"ip_method": "provider",
|
||||
"token": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"provider": "godaddy",
|
||||
"domain": "example.org",
|
||||
"host": "subdomain",
|
||||
"ip_method": "google",
|
||||
"key": "aaaaaaaaaaaaaaaa",
|
||||
"secret": "aaaaaaaaaaaaaaaa"
|
||||
},
|
||||
{
|
||||
"provider": "dreamhost",
|
||||
"domain": "example.info",
|
||||
"ip_method": "opendns",
|
||||
"key": "aaaaaaaaaaaaaaaa"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,15 +9,24 @@ services:
|
||||
volumes:
|
||||
- ./data:/updater/data
|
||||
environment:
|
||||
- DELAY=300s
|
||||
- ROOT_URL=/
|
||||
- PERIOD=5m
|
||||
- IP_METHOD=cycle
|
||||
- IPV4_METHOD=cycle
|
||||
- IPV6_METHOD=cycle
|
||||
- HTTP_TIMEOUT=10s
|
||||
|
||||
# Web UI
|
||||
- LISTENING_PORT=8000
|
||||
- ROOT_URL=/
|
||||
|
||||
# Backup
|
||||
- BACKUP_PERIOD=0 # 0 to disable
|
||||
- BACKUP_DIRECTORY=/updater/data
|
||||
|
||||
# Other
|
||||
- LOG_ENCODING=console
|
||||
- LOG_LEVEL=info
|
||||
- NODE_ID=0
|
||||
- HTTP_TIMEOUT=10s
|
||||
- NODE_ID=-1 # -1 to disable
|
||||
- GOTIFY_URL=
|
||||
- GOTIFY_TOKEN=
|
||||
- BACKUP_PERIOD=0
|
||||
- BACKUP_DIRECTORY=/updater/data
|
||||
restart: always
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,6 +6,6 @@ require (
|
||||
github.com/golang/mock v1.4.3
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/kyokomi/emoji v2.2.2+incompatible
|
||||
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151
|
||||
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e
|
||||
github.com/stretchr/testify v1.5.1
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -78,6 +78,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151 h1:5q8oyhJqgQyW5v427CDC34SobllqiJCLLfS3Z4EeLCI=
|
||||
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
|
||||
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e h1:UNeyDMj3sAlD/tQw03q5OSNw94BH7JGctvlaQ4Mp52U=
|
||||
github.com/qdm12/golibs v0.0.0-20200521202203-48d37a8b053e/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package constants
|
||||
|
||||
import "github.com/qdm12/ddns-updater/internal/models"
|
||||
|
||||
const (
|
||||
HTMLFail models.HTML = `<font color="red"><b>Failure</b></font>`
|
||||
HTMLSuccess models.HTML = `<font color="green"><b>Success</b></font>`
|
||||
HTMLUpdate models.HTML = `<font color="#00CC66"><b>Up to date</b></font>`
|
||||
HTMLUpdating models.HTML = `<font color="orange"><b>Updating</b></font>`
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO have a struct model containing URL, name for each provider
|
||||
HTMLNamecheap models.HTML = "<a href=\"https://namecheap.com\">Namecheap</a>"
|
||||
HTMLGodaddy models.HTML = "<a href=\"https://godaddy.com\">GoDaddy</a>"
|
||||
HTMLDuckDNS models.HTML = "<a href=\"https://duckdns.org\">DuckDNS</a>"
|
||||
HTMLDreamhost models.HTML = "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>"
|
||||
HTMLCloudflare models.HTML = "<a href=\"https://www.cloudflare.com\">Cloudflare</a>"
|
||||
HTMLNoIP models.HTML = "<a href=\"https://www.noip.com/\">NoIP</a>"
|
||||
HTMLDNSPod models.HTML = "<a href=\"https://www.dnspod.cn/\">DNSPod</a>"
|
||||
HTMLInfomaniak models.HTML = "<a href=\"https://www.infomaniak.com/\">Infomaniak</a>"
|
||||
HTMLDdnssde models.HTML = "<a href=\"https://ddnss.de/\">DDNSS.de</a>"
|
||||
)
|
||||
|
||||
const (
|
||||
HTMLGoogle models.HTML = "<a href=\"https://google.com/search?q=ip\">Google</a>"
|
||||
HTMLOpenDNS models.HTML = "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
|
||||
HTMLIfconfig models.HTML = "<a href=\"https://ifconfig.io\">ifconfig.io</a>"
|
||||
HTMLIpinfo models.HTML = "<a href=\"https://ipinfo.io\">ipinfo.io</a>"
|
||||
HTMLIpify models.HTML = "<a href=\"https://api.ipify.org\">api.ipify.org</a>"
|
||||
HTMLIpify6 models.HTML = "<a href=\"https://api6.ipify.org\">api6.ipify.org</a>"
|
||||
HTMLDdnss models.HTML = "<a href=\"https://ddnss.de/meineip.php\">ddnss.de</a>"
|
||||
HTMLDdnss4 models.HTML = "<a href=\"https://ip4.ddnss.de/meineip.php\">ip4.ddnss.de</a>"
|
||||
HTMLDdnss6 models.HTML = "<a href=\"https://ip6.ddnss.de/meineip.php\">ip6.ddns.de</a>"
|
||||
HTMLCycle models.HTML = "Cycling"
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package constants
|
||||
import "github.com/qdm12/ddns-updater/internal/models"
|
||||
|
||||
const (
|
||||
IPv4 models.IPVersion = "ipv4"
|
||||
IPv6 models.IPVersion = "ipv6"
|
||||
IPv4 models.IPVersion = "ipv4"
|
||||
IPv6 models.IPVersion = "ipv6"
|
||||
IPv4OrIPv6 models.IPVersion = "ipv4 or ipv6"
|
||||
)
|
||||
|
||||
@@ -4,50 +4,48 @@ import (
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
PROVIDER models.IPMethod = "provider"
|
||||
OPENDNS models.IPMethod = "opendns"
|
||||
IFCONFIG models.IPMethod = "ifconfig"
|
||||
IPINFO models.IPMethod = "ipinfo"
|
||||
IPIFY models.IPMethod = "ipify"
|
||||
IPIFY6 models.IPMethod = "ipify6"
|
||||
CYCLE models.IPMethod = "cycle"
|
||||
DDNSS models.IPMethod = "ddnss"
|
||||
DDNSS4 models.IPMethod = "ddnss4"
|
||||
DDNSS6 models.IPMethod = "ddnss6"
|
||||
// Retro compatibility only
|
||||
GOOGLE models.IPMethod = "google"
|
||||
)
|
||||
|
||||
func IPMethodMapping() map[models.IPMethod]string {
|
||||
return map[models.IPMethod]string{
|
||||
PROVIDER: string(PROVIDER),
|
||||
CYCLE: string(CYCLE),
|
||||
OPENDNS: "https://diagnostic.opendns.com/myip",
|
||||
IFCONFIG: "https://ifconfig.io/ip",
|
||||
IPINFO: "https://ipinfo.io/ip",
|
||||
IPIFY: "https://api.ipify.org",
|
||||
IPIFY6: "https://api6.ipify.org",
|
||||
DDNSS: "https://ip4.ddnss.de/meineip.php",
|
||||
DDNSS4: "https://ip4.ddnss.de/meineip.php",
|
||||
DDNSS6: "https://ip6.ddnss.de/meineip.php",
|
||||
func IPMethods() []models.IPMethod {
|
||||
return []models.IPMethod{
|
||||
{
|
||||
Name: "cycle",
|
||||
},
|
||||
{
|
||||
Name: "opendns",
|
||||
URL: "https://diagnostic.opendns.com/myip",
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
},
|
||||
{
|
||||
Name: "ifconfig",
|
||||
URL: "https://ifconfig.io/ip",
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
},
|
||||
{
|
||||
Name: "ipinfo",
|
||||
URL: "https://ipinfo.io/ip",
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
},
|
||||
{
|
||||
Name: "ipify",
|
||||
URL: "https://api.ipify.org",
|
||||
IPv4: true,
|
||||
},
|
||||
{
|
||||
Name: "ipify6",
|
||||
URL: "https://api6.ipify.org",
|
||||
IPv6: true,
|
||||
},
|
||||
{
|
||||
Name: "ddnss4",
|
||||
URL: "https://ip4.ddnss.de/meineip.php",
|
||||
IPv4: true,
|
||||
},
|
||||
{
|
||||
Name: "ddnss6",
|
||||
URL: "https://ip6.ddnss.de/meineip.php",
|
||||
IPv6: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func IPMethodChoices() (choices []models.IPMethod) {
|
||||
for choice := range IPMethodMapping() {
|
||||
choices = append(choices, choice)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
func IPMethodExternalChoices() (choices []models.IPMethod) {
|
||||
for _, choice := range IPMethodChoices() {
|
||||
switch choice {
|
||||
case PROVIDER, CYCLE:
|
||||
default:
|
||||
choices = append(choices, choice)
|
||||
}
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package constants
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_IPMethodChoices(t *testing.T) {
|
||||
t.Parallel()
|
||||
choices := IPMethodChoices()
|
||||
assert.ElementsMatch(t, []models.IPMethod{"ipinfo", "ipify", "ipify6", "provider", "cycle", "opendns", "ifconfig", "ddnss", "ddnss4", "ddnss6"}, choices)
|
||||
}
|
||||
|
||||
func Test_IPMethodExternalChoices(t *testing.T) {
|
||||
t.Parallel()
|
||||
choices := IPMethodExternalChoices()
|
||||
assert.ElementsMatch(t, []models.IPMethod{"ipinfo", "ipify", "ipify6", "ifconfig", "opendns", "ddnss", "ddnss4", "ddnss6"}, choices)
|
||||
}
|
||||
@@ -5,26 +5,27 @@ import (
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/persistence"
|
||||
"github.com/qdm12/ddns-updater/internal/records"
|
||||
)
|
||||
|
||||
type Database interface {
|
||||
Close() error
|
||||
Insert(record models.Record) (id int)
|
||||
Select(id int) (record models.Record, err error)
|
||||
SelectAll() (records []models.Record)
|
||||
Update(id int, record models.Record) error
|
||||
Insert(record records.Record) (id int)
|
||||
Select(id int) (record records.Record, err error)
|
||||
SelectAll() (records []records.Record)
|
||||
Update(id int, record records.Record) error
|
||||
// From persistence database
|
||||
GetEvents(domain, host string) (events []models.HistoryEvent, err error)
|
||||
}
|
||||
|
||||
type database struct {
|
||||
data []models.Record
|
||||
data []records.Record
|
||||
sync.RWMutex
|
||||
persistentDB persistence.Database
|
||||
}
|
||||
|
||||
// NewDatabase creates a new in memory database
|
||||
func NewDatabase(data []models.Record, persistentDB persistence.Database) Database {
|
||||
func NewDatabase(data []records.Record, persistentDB persistence.Database) Database {
|
||||
return &database{
|
||||
data: data,
|
||||
persistentDB: persistentDB,
|
||||
|
||||
@@ -3,17 +3,17 @@ package data
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/records"
|
||||
)
|
||||
|
||||
func (db *database) Insert(record models.Record) (id int) {
|
||||
func (db *database) Insert(record records.Record) (id int) {
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
db.data = append(db.data, record)
|
||||
return len(db.data) - 1
|
||||
}
|
||||
|
||||
func (db *database) Select(id int) (record models.Record, err error) {
|
||||
func (db *database) Select(id int) (record records.Record, err error) {
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
if id < 0 {
|
||||
@@ -25,7 +25,7 @@ func (db *database) Select(id int) (record models.Record, err error) {
|
||||
return db.data[id], nil
|
||||
}
|
||||
|
||||
func (db *database) SelectAll() (records []models.Record) {
|
||||
func (db *database) SelectAll() (records []records.Record) {
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
return db.data
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/records"
|
||||
)
|
||||
|
||||
func (db *database) GetEvents(domain, host string) (events []models.HistoryEvent, err error) {
|
||||
return db.persistentDB.GetEvents(domain, host)
|
||||
}
|
||||
|
||||
func (db *database) Update(id int, record models.Record) error {
|
||||
func (db *database) Update(id int, record records.Record) error {
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
if id < 0 {
|
||||
@@ -25,8 +26,8 @@ func (db *database) Update(id int, record models.Record) error {
|
||||
// new IP address added
|
||||
if newCount > currentCount {
|
||||
if err := db.persistentDB.StoreNewIP(
|
||||
record.Settings.Domain,
|
||||
record.Settings.Host,
|
||||
record.Settings.Domain(),
|
||||
record.Settings.Host(),
|
||||
record.History.GetCurrentIP(),
|
||||
record.History.GetSuccessTime(),
|
||||
); err != nil {
|
||||
|
||||
@@ -7,61 +7,31 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/data"
|
||||
"github.com/qdm12/ddns-updater/internal/html"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/logging"
|
||||
)
|
||||
|
||||
// Handler contains a handler function
|
||||
type Handler interface {
|
||||
GetHandlerFunc() http.HandlerFunc
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
rootURL string
|
||||
uiDir string
|
||||
db data.Database
|
||||
logger logging.Logger
|
||||
forceUpdate func()
|
||||
onError func(err error)
|
||||
getTime func() time.Time
|
||||
}
|
||||
|
||||
// NewHandler returns a Handler object
|
||||
func NewHandler(rootURL, uiDir string, db data.Database, logger logging.Logger,
|
||||
forceUpdate func(), onError func(err error)) Handler {
|
||||
return &handler{
|
||||
rootURL: rootURL,
|
||||
uiDir: uiDir,
|
||||
db: db,
|
||||
logger: logger,
|
||||
forceUpdate: forceUpdate,
|
||||
onError: onError,
|
||||
getTime: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHandlerFunc returns a router with all the necessary routes configured
|
||||
func (h *handler) GetHandlerFunc() http.HandlerFunc {
|
||||
// MakeHandler returns a router with all the necessary routes configured
|
||||
func MakeHandler(rootURL, uiDir string, db data.Database, logger logging.Logger, forceUpdate func(), timeNow func() time.Time) http.HandlerFunc {
|
||||
logger = logger.WithPrefix("http server: ")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("received HTTP request at %s", r.RequestURI)
|
||||
logger.Info("HTTP %s %s", r.Method, r.RequestURI)
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/":
|
||||
// TODO: Forms to change existing updates or add some
|
||||
t := template.Must(template.ParseFiles(h.uiDir + "/ui/index.html"))
|
||||
case r.Method == http.MethodGet && r.RequestURI == rootURL+"/":
|
||||
t := template.Must(template.ParseFiles(uiDir + "/index.html"))
|
||||
var htmlData models.HTMLData
|
||||
for _, record := range h.db.SelectAll() {
|
||||
row := html.ConvertRecord(record, h.getTime())
|
||||
for _, record := range db.SelectAll() {
|
||||
row := record.HTML(timeNow())
|
||||
htmlData.Rows = append(htmlData.Rows, row)
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "index.html", htmlData); err != nil {
|
||||
h.logger.Warn(err)
|
||||
logger.Warn(err)
|
||||
fmt.Fprint(w, "An error occurred creating this webpage")
|
||||
}
|
||||
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/update":
|
||||
h.logger.Info("Update started manually")
|
||||
h.forceUpdate()
|
||||
http.Redirect(w, r, h.rootURL, 301)
|
||||
case r.Method == http.MethodGet && r.RequestURI == rootURL+"/update":
|
||||
logger.Info("Update started manually")
|
||||
forceUpdate()
|
||||
http.Redirect(w, r, rootURL, 301)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func IsHealthy(db data.Database, lookupIP lookupIPFunc, logger logging.Logger) (
|
||||
for _, record := range records {
|
||||
if record.Status == constants.FAIL {
|
||||
return fmt.Errorf("%s", record.String())
|
||||
} else if record.Settings.NoDNSLookup {
|
||||
} else if !record.Settings.DNSLookup() {
|
||||
continue
|
||||
}
|
||||
lookedUpIPs, err := lookupIP(record.Settings.BuildDomainName())
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
)
|
||||
|
||||
func ConvertRecord(record models.Record, now time.Time) models.HTMLRow {
|
||||
const NotAvailable = "N/A"
|
||||
row := models.HTMLRow{
|
||||
Domain: convertDomain(record.Settings.BuildDomainName()),
|
||||
Host: models.HTML(record.Settings.Host),
|
||||
Provider: convertProvider(record.Settings.Provider),
|
||||
IPMethod: convertIPMethod(record.Settings.IPMethod, record.Settings.Provider),
|
||||
}
|
||||
message := record.Message
|
||||
if record.Status == constants.UPTODATE {
|
||||
message = "no IP change for " + record.History.GetDurationSinceSuccess(now)
|
||||
}
|
||||
if len(message) > 0 {
|
||||
message = fmt.Sprintf("(%s)", message)
|
||||
}
|
||||
if len(record.Status) == 0 {
|
||||
row.Status = NotAvailable
|
||||
} else {
|
||||
row.Status = models.HTML(fmt.Sprintf("%s %s, %s",
|
||||
convertStatus(record.Status),
|
||||
message,
|
||||
time.Since(record.Time).Round(time.Second).String()+" ago"))
|
||||
}
|
||||
currentIP := record.History.GetCurrentIP()
|
||||
if currentIP != nil {
|
||||
row.CurrentIP = models.HTML(`<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>")
|
||||
} else {
|
||||
row.CurrentIP = NotAvailable
|
||||
}
|
||||
previousIPs := record.History.GetPreviousIPs()
|
||||
row.PreviousIPs = NotAvailable
|
||||
if len(previousIPs) > 0 {
|
||||
var previousIPsStr []string
|
||||
const maxPreviousIPs = 2
|
||||
for i, previousIP := range previousIPs {
|
||||
if i == maxPreviousIPs {
|
||||
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
|
||||
break
|
||||
}
|
||||
previousIPsStr = append(previousIPsStr, previousIP.String())
|
||||
}
|
||||
row.PreviousIPs = models.HTML(strings.Join(previousIPsStr, ", "))
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func convertStatus(status models.Status) models.HTML {
|
||||
switch status {
|
||||
case constants.SUCCESS:
|
||||
return constants.HTMLSuccess
|
||||
case constants.FAIL:
|
||||
return constants.HTMLFail
|
||||
case constants.UPTODATE:
|
||||
return constants.HTMLUpdate
|
||||
case constants.UPDATING:
|
||||
return constants.HTMLUpdating
|
||||
default:
|
||||
return "Unknown status"
|
||||
}
|
||||
}
|
||||
|
||||
func convertProvider(provider models.Provider) models.HTML {
|
||||
switch provider {
|
||||
case constants.NAMECHEAP:
|
||||
return constants.HTMLNamecheap
|
||||
case constants.GODADDY:
|
||||
return constants.HTMLGodaddy
|
||||
case constants.DUCKDNS:
|
||||
return constants.HTMLDuckDNS
|
||||
case constants.DREAMHOST:
|
||||
return constants.HTMLDreamhost
|
||||
case constants.CLOUDFLARE:
|
||||
return constants.HTMLCloudflare
|
||||
case constants.NOIP:
|
||||
return constants.HTMLNoIP
|
||||
case constants.DNSPOD:
|
||||
return constants.HTMLDNSPod
|
||||
case constants.INFOMANIAK:
|
||||
return constants.HTMLInfomaniak
|
||||
case constants.DDNSSDE:
|
||||
return constants.HTMLDdnssde
|
||||
default:
|
||||
s := string(provider)
|
||||
if strings.HasPrefix("https://", s) {
|
||||
shorterName := strings.TrimPrefix(s, "https://")
|
||||
shorterName = strings.TrimSuffix(shorterName, "/")
|
||||
return models.HTML(fmt.Sprintf("<a href=\"%s\">%s</a>", s, shorterName))
|
||||
}
|
||||
return models.HTML(string(provider))
|
||||
}
|
||||
}
|
||||
|
||||
func convertIPMethod(ipMethod models.IPMethod, provider models.Provider) models.HTML {
|
||||
// TODO map to icons
|
||||
switch ipMethod {
|
||||
case constants.PROVIDER:
|
||||
return convertProvider(provider)
|
||||
case constants.OPENDNS:
|
||||
return constants.HTMLOpenDNS
|
||||
case constants.IFCONFIG:
|
||||
return constants.HTMLIfconfig
|
||||
case constants.IPINFO:
|
||||
return constants.HTMLIpinfo
|
||||
case constants.IPIFY:
|
||||
return constants.HTMLIpify
|
||||
case constants.IPIFY6:
|
||||
return constants.HTMLIpify6
|
||||
case constants.DDNSS:
|
||||
return constants.HTMLDdnss
|
||||
case constants.DDNSS4:
|
||||
return constants.HTMLDdnss4
|
||||
case constants.DDNSS6:
|
||||
return constants.HTMLDdnss6
|
||||
case constants.CYCLE:
|
||||
return constants.HTMLCycle
|
||||
default:
|
||||
return models.HTML(string(ipMethod))
|
||||
}
|
||||
}
|
||||
|
||||
func convertDomain(domain string) models.HTML {
|
||||
return models.HTML("<a href=\"http://" + domain + "\">" + domain + "</a>")
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package models
|
||||
type (
|
||||
// Provider is a possible DNS provider
|
||||
Provider string
|
||||
// IPMethod is a method to obtain your public IP address
|
||||
IPMethod string
|
||||
// Status is the record config status
|
||||
Status string
|
||||
// HTML is for constants HTML strings
|
||||
|
||||
@@ -12,7 +12,7 @@ type HTMLRow struct {
|
||||
Domain HTML
|
||||
Host HTML
|
||||
Provider HTML
|
||||
IPMethod HTML
|
||||
IPVersion HTML
|
||||
Status HTML
|
||||
CurrentIP HTML
|
||||
PreviousIPs HTML
|
||||
|
||||
9
internal/models/ipmethod.go
Normal file
9
internal/models/ipmethod.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
// IPMethod is a method to obtain your public IP address
|
||||
type IPMethod struct {
|
||||
Name string
|
||||
URL string
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Settings contains the elements to update the DNS record
|
||||
// nolint: maligned
|
||||
type Settings struct {
|
||||
Domain string
|
||||
Host string
|
||||
Provider Provider
|
||||
IPMethod IPMethod
|
||||
IPVersion IPVersion
|
||||
Delay time.Duration
|
||||
NoDNSLookup bool
|
||||
// Provider dependent fields
|
||||
Password string // Namecheap, Infomaniak, DDNSS and NoIP only
|
||||
Key string // GoDaddy, Dreamhost and Cloudflare only
|
||||
Secret string // GoDaddy only
|
||||
Token string // Cloudflare and DuckDNS only
|
||||
Email string // Cloudflare only
|
||||
UserServiceKey string // Cloudflare only
|
||||
ZoneIdentifier string // Cloudflare only
|
||||
Identifier string // Cloudflare only
|
||||
Proxied bool // Cloudflare only
|
||||
TTL uint // Cloudflare only
|
||||
Username string // NoIP, Infomaniak, DDNSS only
|
||||
}
|
||||
|
||||
func (settings *Settings) String() string {
|
||||
b, _ := json.Marshal(
|
||||
struct {
|
||||
Domain string `json:"domain"`
|
||||
Host string `json:"host"`
|
||||
Provider string `json:"provider"`
|
||||
}{
|
||||
settings.Domain,
|
||||
settings.Host,
|
||||
string(settings.Provider),
|
||||
},
|
||||
)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// BuildDomainName builds the domain name from the domain and the host of the settings
|
||||
func (settings *Settings) BuildDomainName() string {
|
||||
switch settings.Host {
|
||||
case "@":
|
||||
return settings.Domain
|
||||
case "*":
|
||||
return "any." + settings.Domain
|
||||
default:
|
||||
return settings.Host + "." + settings.Domain
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
@@ -16,24 +18,92 @@ import (
|
||||
func GetPublicIP(client network.Client, url string, ipVersion models.IPVersion) (ip net.IP, err error) {
|
||||
content, status, err := client.GetContent(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get public %s address from %s: %s", ipVersion, url, err)
|
||||
return nil, fmt.Errorf("cannot get public %s address: %w", ipVersion, err)
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("cannot get public %s address from %s: HTTP status code %d", ipVersion, url, status)
|
||||
}
|
||||
verifier := verification.NewVerifier()
|
||||
regexSearch := verifier.SearchIPv4
|
||||
if ipVersion == constants.IPv6 {
|
||||
regexSearch = verifier.SearchIPv6
|
||||
s := string(content)
|
||||
switch ipVersion {
|
||||
case constants.IPv4:
|
||||
return searchIP(constants.IPv4, s)
|
||||
case constants.IPv6:
|
||||
return searchIP(constants.IPv6, s)
|
||||
case constants.IPv4OrIPv6:
|
||||
var ipv4Err, ipv6Err error
|
||||
ip, ipv4Err = searchIP(constants.IPv4, s)
|
||||
if ipv4Err != nil {
|
||||
ip, ipv6Err = searchIP(constants.IPv6, s)
|
||||
}
|
||||
if ipv6Err != nil {
|
||||
return nil, fmt.Errorf("%s, %s", ipv4Err, ipv6Err)
|
||||
}
|
||||
return ip, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ip version %q not supported", ipVersion)
|
||||
}
|
||||
ips := regexSearch(string(content))
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no public %s address found at %s", ipVersion, url)
|
||||
} else if len(ips) > 1 {
|
||||
return nil, fmt.Errorf("multiple public %s addresses found at %s: %s", ipVersion, url, strings.Join(ips, " "))
|
||||
}
|
||||
ip = net.ParseIP(ips[0])
|
||||
if ip == nil { // in case the regex is not restrictive enough
|
||||
return nil, fmt.Errorf("Public IP address %q found at %s is not valid", ips[0], url)
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func searchIP(version models.IPVersion, s string) (ip net.IP, err error) {
|
||||
verifier := verification.NewVerifier()
|
||||
var regexSearch func(s string) []string
|
||||
switch version {
|
||||
case constants.IPv4:
|
||||
regexSearch = verifier.SearchIPv4
|
||||
case constants.IPv6:
|
||||
regexSearch = verifier.SearchIPv6
|
||||
default:
|
||||
return nil, fmt.Errorf("ip version %q is not supported for regex search", version)
|
||||
}
|
||||
ips := regexSearch(s)
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no public %s address found", version)
|
||||
}
|
||||
uniqueIPs := make(map[string]struct{})
|
||||
for _, ipString := range ips {
|
||||
uniqueIPs[ipString] = struct{}{}
|
||||
}
|
||||
netIPs := []net.IP{}
|
||||
for ipString := range uniqueIPs {
|
||||
netIP := net.ParseIP(ipString)
|
||||
if netIP == nil || netIPIsPrivate(netIP) {
|
||||
// in case the regex is not restrictive enough
|
||||
// or the IP address is private
|
||||
continue
|
||||
}
|
||||
netIPs = append(netIPs, netIP)
|
||||
}
|
||||
switch len(netIPs) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no public %s address found", version)
|
||||
case 1:
|
||||
return netIPs[0], nil
|
||||
default:
|
||||
sort.Slice(netIPs, func(i, j int) bool {
|
||||
return bytes.Compare(netIPs[i], netIPs[j]) < 0
|
||||
})
|
||||
ips = make([]string, len(netIPs))
|
||||
for i := range netIPs {
|
||||
ips[i] = netIPs[i].String()
|
||||
}
|
||||
return nil, fmt.Errorf("multiple public %s addresses found: %s", version, strings.Join(ips, " "))
|
||||
}
|
||||
}
|
||||
|
||||
func netIPIsPrivate(netIP net.IP) bool {
|
||||
for _, privateCIDRBlock := range [8]string{
|
||||
"127.0.0.1/8", // localhost
|
||||
"10.0.0.0/8", // 24-bit block
|
||||
"172.16.0.0/12", // 20-bit block
|
||||
"192.168.0.0/16", // 16-bit block
|
||||
"169.254.0.0/16", // link local address
|
||||
"::1/128", // localhost IPv6
|
||||
"fc00::/7", // unique local address IPv6
|
||||
"fe80::/10", // link local address IPv6
|
||||
} {
|
||||
_, CIDR, _ := net.ParseCIDR(privateCIDRBlock)
|
||||
if CIDR.Contains(netIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -27,36 +27,47 @@ func Test_GetPublicIP(t *testing.T) {
|
||||
"network error": {
|
||||
IPVersion: constants.IPv4,
|
||||
mockErr: fmt.Errorf("error"),
|
||||
err: fmt.Errorf("cannot get public ipv4 address from https://getmyip.com: error"),
|
||||
err: fmt.Errorf("cannot get public ipv4 address: error"),
|
||||
},
|
||||
"bad status": {
|
||||
IPVersion: constants.IPv4,
|
||||
mockStatus: http.StatusUnauthorized,
|
||||
err: fmt.Errorf("cannot get public ipv4 address from https://getmyip.com: HTTP status code 401"),
|
||||
},
|
||||
"no IPs in content": {
|
||||
"ipv4 address": {
|
||||
IPVersion: constants.IPv4,
|
||||
mockContent: []byte(""),
|
||||
mockContent: []byte("55.55.55.55"),
|
||||
mockStatus: http.StatusOK,
|
||||
err: fmt.Errorf("no public ipv4 address found at https://getmyip.com"),
|
||||
ip: net.IP{55, 55, 55, 55},
|
||||
},
|
||||
"multiple IPs in content": {
|
||||
IPVersion: constants.IPv4,
|
||||
mockContent: []byte("10.10.10.10 50.50.50.50"),
|
||||
mockStatus: http.StatusOK,
|
||||
err: fmt.Errorf("multiple public ipv4 addresses found at https://getmyip.com: 10.10.10.10 50.50.50.50"),
|
||||
},
|
||||
"single IP in content": {
|
||||
IPVersion: constants.IPv4,
|
||||
mockContent: []byte("10.10.10.10"),
|
||||
mockStatus: http.StatusOK,
|
||||
ip: net.IP{10, 10, 10, 10},
|
||||
},
|
||||
"single IPv6 in content": {
|
||||
"ipv6 address": {
|
||||
IPVersion: constants.IPv6,
|
||||
mockContent: []byte("::fe"),
|
||||
mockContent: []byte("ad07:e846:51ac:6cd0:0000:0000:0000:0000"),
|
||||
mockStatus: http.StatusOK,
|
||||
ip: net.IP{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xfe},
|
||||
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
"ipv4 or ipv6 found ipv4": {
|
||||
IPVersion: constants.IPv4OrIPv6,
|
||||
mockContent: []byte("55.55.55.55"),
|
||||
mockStatus: http.StatusOK,
|
||||
ip: net.IP{55, 55, 55, 55},
|
||||
},
|
||||
"ipv4 or ipv6 found ipv6": {
|
||||
IPVersion: constants.IPv4OrIPv6,
|
||||
mockContent: []byte("ad07:e846:51ac:6cd0:0000:0000:0000:0000"),
|
||||
mockStatus: http.StatusOK,
|
||||
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
"ipv4 or ipv6 not found": {
|
||||
IPVersion: constants.IPv4OrIPv6,
|
||||
mockContent: []byte("abc"),
|
||||
mockStatus: http.StatusOK,
|
||||
err: fmt.Errorf("no public ipv4 address found, no public ipv6 address found"),
|
||||
},
|
||||
"unsupported ip version": {
|
||||
IPVersion: models.IPVersion("x"),
|
||||
mockStatus: http.StatusOK,
|
||||
err: fmt.Errorf("ip version \"x\" not supported"),
|
||||
},
|
||||
}
|
||||
const URL = "https://getmyip.com"
|
||||
@@ -79,3 +90,66 @@ func Test_GetPublicIP(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_searchIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
IPVersion models.IPVersion
|
||||
s string
|
||||
ip net.IP
|
||||
err error
|
||||
}{
|
||||
"unsupported ip version": {
|
||||
IPVersion: constants.IPv4OrIPv6,
|
||||
err: fmt.Errorf("ip version \"ipv4 or ipv6\" is not supported for regex search"),
|
||||
},
|
||||
"no content": {
|
||||
IPVersion: constants.IPv4,
|
||||
err: fmt.Errorf("no public ipv4 address found"),
|
||||
},
|
||||
"single ipv4 address": {
|
||||
IPVersion: constants.IPv4,
|
||||
s: "abcd 55.55.55.55 abcd",
|
||||
ip: net.IP{55, 55, 55, 55},
|
||||
},
|
||||
"single ipv6 address": {
|
||||
IPVersion: constants.IPv6,
|
||||
s: "abcd bd07:e846:51ac:6cd0:0000:0000:0000:0000 abcd",
|
||||
ip: net.IP{0xbd, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
"single private ipv4 address": {
|
||||
IPVersion: constants.IPv4,
|
||||
s: "abcd 10.0.0.3 abcd",
|
||||
err: fmt.Errorf("no public ipv4 address found"),
|
||||
},
|
||||
"single private ipv6 address": {
|
||||
IPVersion: constants.IPv6,
|
||||
s: "abcd ::1 abcd",
|
||||
err: fmt.Errorf("no public ipv6 address found"),
|
||||
},
|
||||
"2 ipv4 addresses": {
|
||||
IPVersion: constants.IPv4,
|
||||
s: "55.55.55.55 56.56.56.56",
|
||||
err: fmt.Errorf("multiple public ipv4 addresses found: 55.55.55.55 56.56.56.56"),
|
||||
},
|
||||
"2 ipv6 addresses": {
|
||||
IPVersion: constants.IPv6,
|
||||
s: "bd07:e846:51ac:6cd0:0000:0000:0000:0000 ad07:e846:51ac:6cd0:0000:0000:0000:0000",
|
||||
err: fmt.Errorf("multiple public ipv6 addresses found: ad07:e846:51ac:6cd0:: bd07:e846:51ac:6cd0::"), //nolint:go-lint
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := searchIP(tc.IPVersion, tc.s)
|
||||
if tc.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.True(t, tc.ip.Equal(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,232 +2,21 @@ package params
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
)
|
||||
|
||||
func settingsGeneralChecks(settings models.Settings, matchDomain func(s string) bool) error {
|
||||
switch {
|
||||
case !ipMethodIsValid(settings.IPMethod):
|
||||
return fmt.Errorf("IP method %q is not recognized", settings.IPMethod)
|
||||
case settings.IPVersion != constants.IPv4 && settings.IPVersion != constants.IPv6:
|
||||
return fmt.Errorf("IP version %q is not recognized", settings.IPVersion)
|
||||
case !matchDomain(settings.Domain):
|
||||
return fmt.Errorf("invalid domain name format")
|
||||
case len(settings.Host) == 0:
|
||||
return fmt.Errorf("host cannot be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func settingsIPVersionChecks(ipVersion models.IPVersion, ipMethod models.IPMethod, provider models.Provider) error {
|
||||
func settingsIPVersionChecks(ipVersion models.IPVersion, provider models.Provider) error {
|
||||
switch ipVersion {
|
||||
case constants.IPv4:
|
||||
switch ipMethod {
|
||||
case constants.IPIFY6, constants.DDNSS6:
|
||||
return fmt.Errorf("IP method %s is only for IPv6 addresses", ipMethod)
|
||||
}
|
||||
case constants.IPv4OrIPv6, constants.IPv4:
|
||||
case constants.IPv6:
|
||||
switch ipMethod {
|
||||
case constants.IPIFY, constants.DDNSS4:
|
||||
return fmt.Errorf("IP method %s is only for IPv4 addresses", ipMethod)
|
||||
}
|
||||
switch provider {
|
||||
case constants.GODADDY, constants.CLOUDFLARE, constants.DNSPOD, constants.DREAMHOST, constants.DUCKDNS, constants.NOIP:
|
||||
return fmt.Errorf("IPv6 support for %s is not supported yet", provider)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsIPMethodChecks(ipMethod models.IPMethod, provider models.Provider) error {
|
||||
if ipMethod == constants.PROVIDER {
|
||||
switch provider {
|
||||
case constants.GODADDY, constants.DREAMHOST, constants.CLOUDFLARE, constants.DNSPOD, constants.DDNSSDE:
|
||||
return fmt.Errorf("unsupported IP update method %q", ipMethod)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsNamecheapChecks(password string) error {
|
||||
if !constants.MatchNamecheapPassword(password) {
|
||||
return fmt.Errorf("invalid password format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsGoDaddyChecks(key, secret string) error {
|
||||
switch {
|
||||
case !constants.MatchGodaddyKey(key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case !constants.MatchGodaddySecret(secret):
|
||||
return fmt.Errorf("invalid secret format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsDuckDNSChecks(token, host string) error {
|
||||
switch {
|
||||
case !constants.MatchDuckDNSToken(token):
|
||||
return fmt.Errorf("invalid token format")
|
||||
case host != "@":
|
||||
return fmt.Errorf(`host can only be "@"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsDreamhostChecks(key, host string) error {
|
||||
switch {
|
||||
case !constants.MatchDreamhostKey(key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case host != "@":
|
||||
return fmt.Errorf(`host can only be "@"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsCloudflareChecks(key, email, userServiceKey, token, zoneIdentifier, identifier string, ttl uint, matchEmail func(s string) bool) error {
|
||||
switch {
|
||||
case len(key) > 0: // email and key must be provided
|
||||
switch {
|
||||
case !constants.MatchCloudflareKey(key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case !matchEmail(email):
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
case len(userServiceKey) > 0: // only user service key
|
||||
if !constants.MatchCloudflareKey(key) {
|
||||
return fmt.Errorf("invalid user service key format")
|
||||
}
|
||||
default: // API token only
|
||||
if !constants.MatchCloudflareToken(token) {
|
||||
return fmt.Errorf("invalid API token key format")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(zoneIdentifier) == 0:
|
||||
return fmt.Errorf("zone identifier cannot be empty")
|
||||
case len(identifier) == 0:
|
||||
return fmt.Errorf("identifier cannot be empty")
|
||||
case ttl == 0:
|
||||
return fmt.Errorf("TTL cannot be left to 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsNoIPChecks(username, password, host string) error {
|
||||
switch {
|
||||
case len(username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(username) > 50:
|
||||
return fmt.Errorf("username cannot be longer than 50 characters")
|
||||
case len(password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsDNSPodChecks(token string) error {
|
||||
if len(token) == 0 {
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsInfomaniakChecks(username, password, host string) error {
|
||||
switch {
|
||||
case len(username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingsDdnssdeChecks(username, password, host string) error {
|
||||
switch {
|
||||
case len(username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *reader) isConsistent(settings models.Settings) error {
|
||||
if err := settingsGeneralChecks(settings, r.verifier.MatchDomain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := settingsIPVersionChecks(settings.IPVersion, settings.IPMethod, settings.Provider); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := settingsIPMethodChecks(settings.IPMethod, settings.Provider); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Checks for each DNS provider
|
||||
switch settings.Provider {
|
||||
case constants.NAMECHEAP:
|
||||
if err := settingsNamecheapChecks(settings.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.GODADDY:
|
||||
if err := settingsGoDaddyChecks(settings.Key, settings.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.DUCKDNS:
|
||||
if err := settingsDuckDNSChecks(settings.Token, settings.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.DREAMHOST:
|
||||
if err := settingsDreamhostChecks(settings.Key, settings.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.CLOUDFLARE:
|
||||
if err := settingsCloudflareChecks(settings.Key, settings.Email, settings.UserServiceKey, settings.Token, settings.ZoneIdentifier, settings.Identifier, settings.TTL, r.verifier.MatchEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.NOIP:
|
||||
if err := settingsNoIPChecks(settings.Username, settings.Password, settings.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.DNSPOD:
|
||||
if err := settingsDNSPodChecks(settings.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.INFOMANIAK:
|
||||
if err := settingsInfomaniakChecks(settings.Username, settings.Password, settings.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
case constants.DDNSSDE:
|
||||
if err := settingsDdnssdeChecks(settings.Username, settings.Password, settings.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("provider %q is not supported", settings.Provider)
|
||||
return fmt.Errorf("ip version %q is not valid", ipVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ipMethodIsValid(ipMethod models.IPMethod) bool {
|
||||
for _, possibility := range constants.IPMethodChoices() {
|
||||
if ipMethod == possibility {
|
||||
return true
|
||||
}
|
||||
}
|
||||
url, err := url.Parse(string(ipMethod))
|
||||
if err != nil || url == nil || url.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_ipMethodIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
ipMethod models.IPMethod
|
||||
valid bool
|
||||
}{
|
||||
"empty method": {
|
||||
ipMethod: "",
|
||||
valid: false,
|
||||
},
|
||||
"non existing method": {
|
||||
ipMethod: "abc",
|
||||
valid: false,
|
||||
},
|
||||
"existing method": {
|
||||
ipMethod: "opendns",
|
||||
valid: true,
|
||||
},
|
||||
"http url": {
|
||||
ipMethod: "http://ipinfo.io/ip",
|
||||
valid: false,
|
||||
},
|
||||
"https url": {
|
||||
ipMethod: "https://ipinfo.io/ip",
|
||||
valid: true,
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := ipMethodIsValid(tc.ipMethod)
|
||||
assert.Equal(t, tc.valid, valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,89 +3,104 @@ package params
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/settings"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
// nolint: maligned
|
||||
type settingsType struct {
|
||||
Provider string `json:"provider"`
|
||||
Domain string `json:"domain"`
|
||||
IPMethod string `json:"ip_method"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
Delay uint64 `json:"delay"`
|
||||
NoDNSLookup bool `json:"no_dns_lookup"`
|
||||
Host string `json:"host"`
|
||||
Password string `json:"password"` // Namecheap, NoIP only
|
||||
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
|
||||
Secret string `json:"secret"` // GoDaddy only
|
||||
Token string `json:"token"` // DuckDNS and Cloudflare only
|
||||
Email string `json:"email"` // Cloudflare only
|
||||
Username string `json:"username"` // NoIP only
|
||||
UserServiceKey string `json:"user_service_key"` // Cloudflare only
|
||||
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
|
||||
Identifier string `json:"identifier"` // Cloudflare only
|
||||
Proxied bool `json:"proxied"` // Cloudflare only
|
||||
TTL uint `json:"ttl"` // Cloudflare only
|
||||
type commonSettings struct {
|
||||
Provider string `json:"provider"`
|
||||
Domain string `json:"domain"`
|
||||
Host string `json:"host"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
NoDNSLookup bool `json:"no_dns_lookup"`
|
||||
// Retro values for warnings
|
||||
IPMethod *string `json:"ip_method,omitempty"`
|
||||
Delay *uint64 `json:"delay,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings obtain the update settings from config.json
|
||||
func (r *reader) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
|
||||
func (r *reader) GetSettings(filePath string) (allSettings []settings.Settings, warnings []string, err error) {
|
||||
bytes, err := r.readFile(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var config struct {
|
||||
Settings []settingsType `json:"settings"`
|
||||
}
|
||||
config := struct {
|
||||
CommonSettings []commonSettings `json:"settings"`
|
||||
}{}
|
||||
rawConfig := struct {
|
||||
Settings []json.RawMessage `json:"settings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(bytes, &config); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, s := range config.Settings {
|
||||
switch models.Provider(s.Provider) {
|
||||
if err := json.Unmarshal(bytes, &rawConfig); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
verifier := verification.NewVerifier()
|
||||
allSettings = make([]settings.Settings, len(config.CommonSettings))
|
||||
for i, s := range config.CommonSettings {
|
||||
if s.Delay != nil {
|
||||
warnings = append(warnings, "per record delay is not supported anymore and will be ignored")
|
||||
}
|
||||
if s.IPMethod != nil {
|
||||
warnings = append(warnings, "per record ip method is not supported anymore and will be ignored")
|
||||
}
|
||||
provider := models.Provider(s.Provider)
|
||||
switch provider {
|
||||
case constants.DREAMHOST, constants.DUCKDNS:
|
||||
s.Host = "@" // only choice available
|
||||
if s.Host != "" && s.Host != "@" {
|
||||
warnings = append(warnings, fmt.Sprintf("Provider %s only supports @ host configurations, forcing host to @", provider))
|
||||
}
|
||||
s.Host = "@"
|
||||
}
|
||||
ipMethod := models.IPMethod(s.IPMethod)
|
||||
// Retro compatibility
|
||||
if ipMethod == constants.GOOGLE {
|
||||
r.logger.Warn("IP Method %q is no longer valid, please change it. Defaulting it to %s", constants.GOOGLE, constants.CYCLE)
|
||||
ipMethod = constants.CYCLE
|
||||
if len(s.Host) == 0 {
|
||||
return nil, nil, fmt.Errorf("host cannot be empty")
|
||||
}
|
||||
if !verifier.MatchDomain(s.Domain) {
|
||||
return nil, nil, fmt.Errorf("invalid domain name format %q", s.Domain)
|
||||
}
|
||||
|
||||
ipVersion := models.IPVersion(s.IPVersion)
|
||||
if len(ipVersion) == 0 {
|
||||
ipVersion = constants.IPv4 // default
|
||||
ipVersion = constants.IPv4OrIPv6 // default
|
||||
}
|
||||
setting := models.Settings{
|
||||
Provider: models.Provider(s.Provider),
|
||||
Domain: s.Domain,
|
||||
Host: s.Host,
|
||||
IPMethod: ipMethod,
|
||||
IPVersion: ipVersion,
|
||||
Delay: time.Second * time.Duration(s.Delay),
|
||||
NoDNSLookup: s.NoDNSLookup,
|
||||
Password: s.Password,
|
||||
Key: s.Key,
|
||||
Secret: s.Secret,
|
||||
Token: s.Token,
|
||||
Email: s.Email,
|
||||
Username: s.Username,
|
||||
UserServiceKey: s.UserServiceKey,
|
||||
ZoneIdentifier: s.ZoneIdentifier,
|
||||
Identifier: s.Identifier,
|
||||
Proxied: s.Proxied,
|
||||
TTL: s.TTL,
|
||||
if err := settingsIPVersionChecks(ipVersion, provider); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := r.isConsistent(setting); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
|
||||
continue
|
||||
var settingsConstructor settings.Constructor
|
||||
switch provider {
|
||||
case constants.CLOUDFLARE:
|
||||
settingsConstructor = settings.NewCloudflare
|
||||
case constants.DDNSSDE:
|
||||
settingsConstructor = settings.NewDdnss
|
||||
case constants.DNSPOD:
|
||||
settingsConstructor = settings.NewDNSPod
|
||||
case constants.DREAMHOST:
|
||||
settingsConstructor = settings.NewDreamhost
|
||||
case constants.DUCKDNS:
|
||||
settingsConstructor = settings.NewDuckdns
|
||||
case constants.GODADDY:
|
||||
settingsConstructor = settings.NewGodaddy
|
||||
case constants.INFOMANIAK:
|
||||
settingsConstructor = settings.NewInfomaniak
|
||||
case constants.NAMECHEAP:
|
||||
settingsConstructor = settings.NewNamecheap
|
||||
case constants.NOIP:
|
||||
settingsConstructor = settings.NewNoip
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("provider %q is not supported", provider)
|
||||
}
|
||||
allSettings[i], err = settingsConstructor(rawConfig.Settings[i], s.Domain, s.Host, ipVersion, s.NoDNSLookup)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
settings = append(settings, setting)
|
||||
}
|
||||
if len(settings) == 0 {
|
||||
return nil, warnings, fmt.Errorf("no settings found in config.json")
|
||||
if len(allSettings) == 0 {
|
||||
warnings = append(warnings, "no settings found in config.json")
|
||||
}
|
||||
return settings, warnings, nil
|
||||
return allSettings, warnings, nil
|
||||
}
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/settings"
|
||||
"github.com/qdm12/golibs/logging"
|
||||
"github.com/qdm12/golibs/params"
|
||||
libparams "github.com/qdm12/golibs/params"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
const https = "https"
|
||||
|
||||
type Reader interface {
|
||||
GetSettings(filePath string) (settings []models.Settings, warnings []string, err error)
|
||||
GetDataDir(currentDir string) (string, error)
|
||||
GetListeningPort() (listeningPort, warning string, err error)
|
||||
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
|
||||
GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error)
|
||||
GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error)
|
||||
GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error)
|
||||
GetDelay(setters ...libparams.GetEnvSetter) (duration time.Duration, err error)
|
||||
GetExeDir() (dir string, err error)
|
||||
// JSON
|
||||
GetSettings(filePath string) (allSettings []settings.Settings, warnings []string, err error)
|
||||
|
||||
// Core
|
||||
GetPeriod() (period time.Duration, warnings []string, err error)
|
||||
GetIPMethod() (method models.IPMethod, err error)
|
||||
GetIPv4Method() (method models.IPMethod, err error)
|
||||
GetIPv6Method() (method models.IPMethod, err error)
|
||||
GetHTTPTimeout() (duration time.Duration, err error)
|
||||
|
||||
// File paths
|
||||
GetExeDir() (dir string, err error)
|
||||
GetDataDir(currentDir string) (string, error)
|
||||
|
||||
// Web UI
|
||||
GetListeningPort() (listeningPort, warning string, err error)
|
||||
GetRootURL() (rootURL string, err error)
|
||||
|
||||
// Backup
|
||||
GetBackupPeriod() (duration time.Duration, err error)
|
||||
GetBackupDirectory() (directory string, err error)
|
||||
|
||||
// Other
|
||||
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
|
||||
GetGotifyURL() (URL *url.URL, err error)
|
||||
GetGotifyToken() (token string, err error)
|
||||
|
||||
// Version getters
|
||||
GetVersion() string
|
||||
GetBuildDate() string
|
||||
@@ -34,7 +54,6 @@ type Reader interface {
|
||||
type reader struct {
|
||||
envParams libparams.EnvParams
|
||||
verifier verification.Verifier
|
||||
logger logging.Logger
|
||||
readFile func(filename string) ([]byte, error)
|
||||
}
|
||||
|
||||
@@ -42,7 +61,6 @@ func NewReader(logger logging.Logger) Reader {
|
||||
return &reader{
|
||||
envParams: libparams.NewEnvParams(),
|
||||
verifier: verification.NewVerifier(),
|
||||
logger: logger,
|
||||
readFile: ioutil.ReadFile,
|
||||
}
|
||||
}
|
||||
@@ -61,26 +79,107 @@ func (r *reader) GetLoggerConfig() (encoding logging.Encoding, level logging.Lev
|
||||
return r.envParams.GetLoggerConfig()
|
||||
}
|
||||
|
||||
func (r *reader) GetGotifyURL(setters ...libparams.GetEnvSetter) (url *url.URL, err error) {
|
||||
func (r *reader) GetGotifyURL() (url *url.URL, err error) {
|
||||
return r.envParams.GetGotifyURL()
|
||||
}
|
||||
|
||||
func (r *reader) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
|
||||
func (r *reader) GetGotifyToken() (token string, err error) {
|
||||
return r.envParams.GetGotifyToken()
|
||||
}
|
||||
|
||||
func (r *reader) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
|
||||
func (r *reader) GetRootURL() (rootURL string, err error) {
|
||||
return r.envParams.GetRootURL()
|
||||
}
|
||||
|
||||
func (r *reader) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, err error) {
|
||||
func (r *reader) GetPeriod() (period time.Duration, warnings []string, err error) {
|
||||
// Backward compatibility
|
||||
n, err := r.envParams.GetEnvInt("DELAY", libparams.Compulsory()) // TODO change to PERIOD
|
||||
if err == nil { // integer only, treated as seconds
|
||||
r.logger.Warn("The value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n)
|
||||
return time.Duration(n) * time.Second, nil
|
||||
n, err := r.envParams.GetEnvInt("DELAY", libparams.Compulsory())
|
||||
if err == nil { // integer only, treated as seconds
|
||||
return time.Duration(n) * time.Second,
|
||||
[]string{
|
||||
"the environment variable DELAY should be changed to PERIOD",
|
||||
fmt.Sprintf("the value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n),
|
||||
}, nil
|
||||
}
|
||||
return r.envParams.GetDuration("DELAY", setters...)
|
||||
period, err = r.envParams.GetDuration("DELAY", libparams.Compulsory())
|
||||
if err == nil {
|
||||
return period,
|
||||
[]string{
|
||||
"the environment variable DELAY should be changed to PERIOD",
|
||||
}, nil
|
||||
}
|
||||
period, err = r.envParams.GetDuration("PERIOD", libparams.Default("10m"))
|
||||
return period, nil, err
|
||||
}
|
||||
|
||||
func (r *reader) GetIPMethod() (method models.IPMethod, err error) {
|
||||
s, err := r.envParams.GetEnv("IP_METHOD", params.Default("cycle"))
|
||||
if err != nil {
|
||||
return method, err
|
||||
}
|
||||
for _, choice := range constants.IPMethods() {
|
||||
if choice.Name == s {
|
||||
return choice, nil
|
||||
}
|
||||
}
|
||||
url, err := url.Parse(s)
|
||||
if err != nil || url == nil || url.Scheme != https {
|
||||
return method, fmt.Errorf("ip method %q is not valid", s)
|
||||
}
|
||||
return models.IPMethod{
|
||||
Name: s,
|
||||
URL: s,
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *reader) GetIPv4Method() (method models.IPMethod, err error) {
|
||||
s, err := r.envParams.GetEnv("IPV4_METHOD", params.Default("cycle"))
|
||||
if err != nil {
|
||||
return method, err
|
||||
}
|
||||
for _, choice := range constants.IPMethods() {
|
||||
if choice.Name == s {
|
||||
if s != "cycle" && !choice.IPv4 {
|
||||
return method, fmt.Errorf("ip method %s does not support IPv4", s)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
}
|
||||
url, err := url.Parse(s)
|
||||
if err != nil || url == nil || url.Scheme != https {
|
||||
return method, fmt.Errorf("ipv4 method %q is not valid", s)
|
||||
}
|
||||
return models.IPMethod{
|
||||
Name: s,
|
||||
URL: s,
|
||||
IPv4: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *reader) GetIPv6Method() (method models.IPMethod, err error) {
|
||||
s, err := r.envParams.GetEnv("IPV6_METHOD", params.Default("cycle"))
|
||||
if err != nil {
|
||||
return method, err
|
||||
}
|
||||
for _, choice := range constants.IPMethods() {
|
||||
if choice.Name == s {
|
||||
if s != "cycle" && !choice.IPv6 {
|
||||
return method, fmt.Errorf("ip method %s does not support IPv6", s)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
}
|
||||
url, err := url.Parse(s)
|
||||
if err != nil || url == nil || url.Scheme != https {
|
||||
return method, fmt.Errorf("ipv6 method %q is not valid", s)
|
||||
}
|
||||
return models.IPMethod{
|
||||
Name: s,
|
||||
URL: s,
|
||||
IPv4: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *reader) GetExeDir() (dir string, err error) {
|
||||
|
||||
66
internal/records/html.go
Normal file
66
internal/records/html.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
)
|
||||
|
||||
func (r *Record) HTML(now time.Time) models.HTMLRow {
|
||||
const NotAvailable = "N/A"
|
||||
row := r.Settings.HTML()
|
||||
message := r.Message
|
||||
if r.Status == constants.UPTODATE {
|
||||
message = "no IP change for " + r.History.GetDurationSinceSuccess(now)
|
||||
}
|
||||
if len(message) > 0 {
|
||||
message = fmt.Sprintf("(%s)", message)
|
||||
}
|
||||
if len(r.Status) == 0 {
|
||||
row.Status = NotAvailable
|
||||
} else {
|
||||
row.Status = models.HTML(fmt.Sprintf("%s %s, %s",
|
||||
convertStatus(r.Status),
|
||||
message,
|
||||
time.Since(r.Time).Round(time.Second).String()+" ago"))
|
||||
}
|
||||
currentIP := r.History.GetCurrentIP()
|
||||
if currentIP != nil {
|
||||
row.CurrentIP = models.HTML(`<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>")
|
||||
} else {
|
||||
row.CurrentIP = NotAvailable
|
||||
}
|
||||
previousIPs := r.History.GetPreviousIPs()
|
||||
row.PreviousIPs = NotAvailable
|
||||
if len(previousIPs) > 0 {
|
||||
var previousIPsStr []string
|
||||
const maxPreviousIPs = 2
|
||||
for i, previousIP := range previousIPs {
|
||||
if i == maxPreviousIPs {
|
||||
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
|
||||
break
|
||||
}
|
||||
previousIPsStr = append(previousIPsStr, previousIP.String())
|
||||
}
|
||||
row.PreviousIPs = models.HTML(strings.Join(previousIPsStr, ", "))
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func convertStatus(status models.Status) models.HTML {
|
||||
switch status {
|
||||
case constants.SUCCESS:
|
||||
return `<font color="green"><b>Success</b></font>`
|
||||
case constants.FAIL:
|
||||
return `<font color="red"><b>Failure</b></font>`
|
||||
case constants.UPTODATE:
|
||||
return `<font color="#00CC66"><b>Up to date</b></font>`
|
||||
case constants.UPDATING:
|
||||
return `<font color="orange"><b>Updating</b></font>`
|
||||
default:
|
||||
return "Unknown status"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
package models
|
||||
package records
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/settings"
|
||||
)
|
||||
|
||||
// Record contains all the information to update and display a DNS record
|
||||
type Record struct { // internal
|
||||
Settings Settings // fixed
|
||||
History History // past information
|
||||
Status Status
|
||||
Settings settings.Settings // fixed
|
||||
History models.History // past information
|
||||
Status models.Status
|
||||
Message string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// NewRecord returns a new Record with settings and some history
|
||||
func NewRecord(settings Settings, events []HistoryEvent) Record {
|
||||
// New returns a new Record with settings and some history
|
||||
func New(settings settings.Settings, events []models.HistoryEvent) Record {
|
||||
return Record{
|
||||
Settings: settings,
|
||||
History: events,
|
||||
199
internal/settings/cloudflare.go
Normal file
199
internal/settings/cloudflare.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
netlib "github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type cloudflare struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
key string
|
||||
token string
|
||||
email string
|
||||
userServiceKey string
|
||||
zoneIdentifier string
|
||||
identifier string
|
||||
proxied bool
|
||||
ttl uint
|
||||
}
|
||||
|
||||
func NewCloudflare(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Key string `json:"key"`
|
||||
Token string `json:"token"`
|
||||
Email string `json:"email"`
|
||||
UserServiceKey string `json:"user_service_key"`
|
||||
ZoneIdentifier string `json:"zone_identifier"`
|
||||
Identifier string `json:"identifier"`
|
||||
Proxied bool `json:"proxied"`
|
||||
TTL uint `json:"ttl"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &cloudflare{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
key: extraSettings.Key,
|
||||
token: extraSettings.Token,
|
||||
email: extraSettings.Email,
|
||||
userServiceKey: extraSettings.UserServiceKey,
|
||||
zoneIdentifier: extraSettings.ZoneIdentifier,
|
||||
identifier: extraSettings.Identifier,
|
||||
proxied: extraSettings.Proxied,
|
||||
ttl: extraSettings.TTL,
|
||||
}
|
||||
if err := c.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *cloudflare) isValid() error {
|
||||
switch {
|
||||
case len(c.key) > 0: // email and key must be provided
|
||||
switch {
|
||||
case !constants.MatchCloudflareKey(c.key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case !verification.NewVerifier().MatchEmail(c.email):
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
case len(c.userServiceKey) > 0: // only user service key
|
||||
if !constants.MatchCloudflareKey(c.key) {
|
||||
return fmt.Errorf("invalid user service key format")
|
||||
}
|
||||
default: // API token only
|
||||
if !constants.MatchCloudflareToken(c.token) {
|
||||
return fmt.Errorf("invalid API token key format")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(c.zoneIdentifier) == 0:
|
||||
return fmt.Errorf("zone identifier cannot be empty")
|
||||
case len(c.identifier) == 0:
|
||||
return fmt.Errorf("identifier cannot be empty")
|
||||
case c.ttl == 0:
|
||||
return fmt.Errorf("TTL cannot be left to 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflare) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Cloudflare]", c.domain, c.host)
|
||||
}
|
||||
|
||||
func (c *cloudflare) Domain() string {
|
||||
return c.domain
|
||||
}
|
||||
|
||||
func (c *cloudflare) Host() string {
|
||||
return c.host
|
||||
}
|
||||
|
||||
func (c *cloudflare) IPVersion() models.IPVersion {
|
||||
return c.ipVersion
|
||||
}
|
||||
|
||||
func (c *cloudflare) DNSLookup() bool {
|
||||
return c.dnsLookup
|
||||
}
|
||||
|
||||
func (c *cloudflare) BuildDomainName() string {
|
||||
return buildDomainName(c.host, c.domain)
|
||||
}
|
||||
|
||||
func (c *cloudflare) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", c.BuildDomainName(), c.BuildDomainName())),
|
||||
Host: models.HTML(c.Host()),
|
||||
Provider: "<a href=\"https://www.cloudflare.com\">Cloudflare</a>",
|
||||
IPVersion: models.HTML(c.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cloudflare) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
type cloudflarePutBody struct {
|
||||
Type string `json:"type"` // forced to A
|
||||
Name string `json:"name"` // DNS record name i.e. example.com
|
||||
Content string `json:"content"` // ip address
|
||||
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
|
||||
TTL uint `json:"ttl"`
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.cloudflare.com",
|
||||
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", c.zoneIdentifier, c.identifier),
|
||||
}
|
||||
r, err := network.BuildHTTPPut(
|
||||
u.String(),
|
||||
cloudflarePutBody{
|
||||
Type: "A",
|
||||
Name: c.host,
|
||||
Content: ip.String(),
|
||||
Proxied: c.proxied,
|
||||
TTL: c.ttl,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
switch {
|
||||
case len(c.token) > 0:
|
||||
r.Header.Set("Authorization", "Bearer "+c.token)
|
||||
case len(c.userServiceKey) > 0:
|
||||
r.Header.Set("X-Auth-User-Service-Key", c.userServiceKey)
|
||||
case len(c.email) > 0 && len(c.key) > 0:
|
||||
r.Header.Set("X-Auth-Email", c.email)
|
||||
r.Header.Set("X-Auth-Key", c.key)
|
||||
default:
|
||||
return nil, fmt.Errorf("email and key are both unset and user service key is not set and no token was provided")
|
||||
}
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status > http.StatusUnsupportedMediaType {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var parsedJSON struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Result struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||
return nil, err
|
||||
} else if !parsedJSON.Success {
|
||||
var errStr string
|
||||
for _, e := range parsedJSON.Errors {
|
||||
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
|
||||
}
|
||||
return nil, fmt.Errorf(errStr)
|
||||
}
|
||||
newIP = net.ParseIP(parsedJSON.Result.Content)
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
|
||||
} else if !newIP.Equal(ip) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
}
|
||||
142
internal/settings/ddnss.go
Normal file
142
internal/settings/ddnss.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type ddnss struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
username string
|
||||
password string
|
||||
useProviderIP bool
|
||||
}
|
||||
|
||||
func NewDdnss(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
UseProviderIP bool `json:"provider_ip"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &ddnss{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
username: extraSettings.Username,
|
||||
password: extraSettings.Password,
|
||||
useProviderIP: extraSettings.UseProviderIP,
|
||||
}
|
||||
if err := d.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *ddnss) isValid() error {
|
||||
switch {
|
||||
case len(d.username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(d.password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case d.host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *ddnss) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Ddnss]", d.domain, d.host)
|
||||
}
|
||||
|
||||
func (d *ddnss) Domain() string {
|
||||
return d.domain
|
||||
}
|
||||
|
||||
func (d *ddnss) Host() string {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func (d *ddnss) IPVersion() models.IPVersion {
|
||||
return d.ipVersion
|
||||
}
|
||||
|
||||
func (d *ddnss) DNSLookup() bool {
|
||||
return d.dnsLookup
|
||||
}
|
||||
|
||||
func (d *ddnss) BuildDomainName() string {
|
||||
return buildDomainName(d.host, d.domain)
|
||||
}
|
||||
|
||||
func (d *ddnss) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
|
||||
Host: models.HTML(d.Host()),
|
||||
Provider: "<a href=\"https://ddnss.de/\">DDNSS.de</a>",
|
||||
IPVersion: models.HTML(d.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ddnss) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.ddnss.de",
|
||||
Path: "/upd.php",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("user", d.username)
|
||||
values.Set("pwd", d.password)
|
||||
fqdn := d.domain
|
||||
if d.host != "@" {
|
||||
fqdn = d.host + "." + d.domain
|
||||
}
|
||||
values.Set("host", fqdn)
|
||||
if !d.useProviderIP {
|
||||
if ip.To4() == nil { // ipv6
|
||||
values.Set("ip6", ip.String())
|
||||
} else {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(content)
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("received status %d with message: %s", status, s)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(s, "badysys"):
|
||||
return nil, fmt.Errorf("ddnss.de: invalid system parameter")
|
||||
case strings.Contains(s, "badauth"):
|
||||
return nil, fmt.Errorf("ddnss.de: bad authentication")
|
||||
case strings.Contains(s, "notfqdn"):
|
||||
return nil, fmt.Errorf("ddnss.de: hostname %q does not exist", fqdn)
|
||||
case strings.Contains(s, "Updated 1 hostname"):
|
||||
return ip, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown response received from ddnss.de: %s", s)
|
||||
}
|
||||
}
|
||||
177
internal/settings/dnspod.go
Normal file
177
internal/settings/dnspod.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
type dnspod struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
token string
|
||||
}
|
||||
|
||||
func NewDNSPod(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Token string `json:"token"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &dnspod{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
token: extraSettings.Token,
|
||||
}
|
||||
if err := d.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *dnspod) isValid() error {
|
||||
if len(d.token) == 0 {
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dnspod) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: DNSPod]", d.domain, d.host)
|
||||
}
|
||||
|
||||
func (d *dnspod) Domain() string {
|
||||
return d.domain
|
||||
}
|
||||
|
||||
func (d *dnspod) Host() string {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func (d *dnspod) IPVersion() models.IPVersion {
|
||||
return d.ipVersion
|
||||
}
|
||||
|
||||
func (d *dnspod) DNSLookup() bool {
|
||||
return d.dnsLookup
|
||||
}
|
||||
|
||||
func (d *dnspod) BuildDomainName() string {
|
||||
return buildDomainName(d.host, d.domain)
|
||||
}
|
||||
|
||||
func (d *dnspod) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
|
||||
Host: models.HTML(d.Host()),
|
||||
Provider: "<a href=\"https://www.dnspod.cn/\">DNSPod</a>",
|
||||
IPVersion: models.HTML(d.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dnspod) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dnsapi.cn",
|
||||
Path: "/Record.List",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("login_token", d.token)
|
||||
values.Set("format", "json")
|
||||
values.Set("domain", d.domain)
|
||||
values.Set("length", "200")
|
||||
values.Set("sub_domain", d.host)
|
||||
values.Set("record_type", "A")
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var recordResp struct {
|
||||
Records []struct {
|
||||
ID string `json:"id"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Line string `json:"line"`
|
||||
} `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &recordResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recordID, recordLine string
|
||||
for _, record := range recordResp.Records {
|
||||
if record.Type == "A" && record.Name == d.host {
|
||||
receivedIP := net.ParseIP(record.Value)
|
||||
if ip.Equal(receivedIP) {
|
||||
return ip, nil
|
||||
}
|
||||
recordID = record.ID
|
||||
recordLine = record.Line
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(recordID) == 0 {
|
||||
return nil, fmt.Errorf("record not found")
|
||||
}
|
||||
|
||||
u.Path = "/Record.Ddns"
|
||||
values = url.Values{}
|
||||
values.Set("login_token", d.token)
|
||||
values.Set("format", "json")
|
||||
values.Set("domain", d.domain)
|
||||
values.Set("record_id", recordID)
|
||||
values.Set("value", ip.String())
|
||||
values.Set("record_line", recordLine)
|
||||
values.Set("sub_domain", d.host)
|
||||
u.RawQuery = values.Encode()
|
||||
r, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err = client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var ddnsResp struct {
|
||||
Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
} `json:"record"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &ddnsResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receivedIP := net.ParseIP(ddnsResp.Record.Value)
|
||||
if !ip.Equal(receivedIP) {
|
||||
return nil, fmt.Errorf("ip not set")
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package update
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,11 +8,113 @@ import (
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
type dreamhost struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
key string
|
||||
}
|
||||
|
||||
func NewDreamhost(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Key string `json:"key"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &dreamhost{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
key: extraSettings.Key,
|
||||
}
|
||||
if err := d.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *dreamhost) isValid() error {
|
||||
switch {
|
||||
case !constants.MatchDreamhostKey(d.key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case d.host != "@":
|
||||
return fmt.Errorf(`host can only be "@"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dreamhost) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Dreamhost]", d.domain, d.host)
|
||||
}
|
||||
|
||||
func (d *dreamhost) Domain() string {
|
||||
return d.domain
|
||||
}
|
||||
|
||||
func (d *dreamhost) Host() string {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func (d *dreamhost) IPVersion() models.IPVersion {
|
||||
return d.ipVersion
|
||||
}
|
||||
|
||||
func (d *dreamhost) DNSLookup() bool {
|
||||
return d.dnsLookup
|
||||
}
|
||||
|
||||
func (d *dreamhost) BuildDomainName() string {
|
||||
return buildDomainName(d.host, d.domain)
|
||||
}
|
||||
|
||||
func (d *dreamhost) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
|
||||
Host: models.HTML(d.Host()),
|
||||
Provider: "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>",
|
||||
IPVersion: models.HTML(d.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
const success = "success"
|
||||
|
||||
func (d *dreamhost) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
records, err := listDreamhostRecords(client, d.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var oldIP net.IP
|
||||
for _, data := range records.Data {
|
||||
if data.Type == "A" && data.Record == d.BuildDomainName() {
|
||||
if data.Editable == "0" {
|
||||
return nil, fmt.Errorf("record data is not editable")
|
||||
}
|
||||
oldIP = net.ParseIP(data.Value)
|
||||
if ip.Equal(oldIP) { // success, nothing to change
|
||||
return ip, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if oldIP != nil { // Found editable record with a different IP address, so remove it
|
||||
if err := removeDreamhostRecord(client, d.key, d.domain, oldIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return ip, addDreamhostRecord(client, d.key, d.domain, ip)
|
||||
}
|
||||
|
||||
type (
|
||||
dreamHostRecords struct {
|
||||
Result string `json:"result"`
|
||||
@@ -124,32 +226,3 @@ func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateDreamhost(client network.Client, domain, key, domainName string, ip net.IP) error {
|
||||
if ip == nil {
|
||||
return fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
records, err := listDreamhostRecords(client, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var oldIP net.IP
|
||||
for _, data := range records.Data {
|
||||
if data.Type == "A" && data.Record == domainName {
|
||||
if data.Editable == "0" {
|
||||
return fmt.Errorf("record data is not editable")
|
||||
}
|
||||
oldIP = net.ParseIP(data.Value)
|
||||
if ip.Equal(oldIP) { // success, nothing to change
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if oldIP != nil { // Found editable record with a different IP address, so remove it
|
||||
if err := removeDreamhostRecord(client, key, domain, oldIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return addDreamhostRecord(client, key, domain, ip)
|
||||
}
|
||||
138
internal/settings/duckdns.go
Normal file
138
internal/settings/duckdns.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type duckdns struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
token string
|
||||
useProviderIP bool
|
||||
}
|
||||
|
||||
func NewDuckdns(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Token string `json:"token"`
|
||||
UseProviderIP bool `json:"provider_ip"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &duckdns{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
token: extraSettings.Token,
|
||||
useProviderIP: extraSettings.UseProviderIP,
|
||||
}
|
||||
if err := d.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *duckdns) isValid() error {
|
||||
switch {
|
||||
case !constants.MatchDuckDNSToken(d.token):
|
||||
return fmt.Errorf("invalid token format")
|
||||
case d.host != "@":
|
||||
return fmt.Errorf(`host can only be "@"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *duckdns) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Duckdns]", d.domain, d.host)
|
||||
}
|
||||
|
||||
func (d *duckdns) Domain() string {
|
||||
return d.domain
|
||||
}
|
||||
|
||||
func (d *duckdns) Host() string {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func (d *duckdns) IPVersion() models.IPVersion {
|
||||
return d.ipVersion
|
||||
}
|
||||
|
||||
func (d *duckdns) DNSLookup() bool {
|
||||
return d.dnsLookup
|
||||
}
|
||||
|
||||
func (d *duckdns) BuildDomainName() string {
|
||||
return buildDomainName(d.host, d.domain)
|
||||
}
|
||||
|
||||
func (d *duckdns) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
|
||||
Host: models.HTML(d.Host()),
|
||||
Provider: "<a href=\"https://duckdns.org\">DuckDNS</a>",
|
||||
IPVersion: models.HTML(d.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *duckdns) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.duckdns.org",
|
||||
Path: "/update",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("verbose", "true")
|
||||
values.Set("domains", d.domain)
|
||||
values.Set("token", d.token)
|
||||
u.RawQuery = values.Encode()
|
||||
if !d.useProviderIP {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
s := string(content)
|
||||
switch {
|
||||
case len(s) < 2:
|
||||
return nil, fmt.Errorf("response %q is too short", s)
|
||||
case s[0:2] == "KO":
|
||||
return nil, fmt.Errorf("invalid domain token combination")
|
||||
case s[0:2] == "OK":
|
||||
ips := verification.NewVerifier().SearchIPv4(s)
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no IP address in response")
|
||||
}
|
||||
newIP = net.ParseIP(ips[0])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||
}
|
||||
if ip != nil && !newIP.Equal(ip) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid response %q", s)
|
||||
}
|
||||
}
|
||||
123
internal/settings/godaddy.go
Normal file
123
internal/settings/godaddy.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
netlib "github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
type godaddy struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
key string
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewGodaddy(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &godaddy{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
key: extraSettings.Key,
|
||||
secret: extraSettings.Secret,
|
||||
}
|
||||
if err := g.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (g *godaddy) isValid() error {
|
||||
switch {
|
||||
case !constants.MatchGodaddyKey(g.key):
|
||||
return fmt.Errorf("invalid key format")
|
||||
case !constants.MatchGodaddySecret(g.secret):
|
||||
return fmt.Errorf("invalid secret format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *godaddy) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Godaddy]", g.domain, g.host)
|
||||
}
|
||||
|
||||
func (g *godaddy) Domain() string {
|
||||
return g.domain
|
||||
}
|
||||
|
||||
func (g *godaddy) Host() string {
|
||||
return g.host
|
||||
}
|
||||
|
||||
func (g *godaddy) IPVersion() models.IPVersion {
|
||||
return g.ipVersion
|
||||
}
|
||||
|
||||
func (g *godaddy) DNSLookup() bool {
|
||||
return g.dnsLookup
|
||||
}
|
||||
|
||||
func (g *godaddy) BuildDomainName() string {
|
||||
return buildDomainName(g.host, g.domain)
|
||||
}
|
||||
|
||||
func (g *godaddy) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", g.BuildDomainName(), g.BuildDomainName())),
|
||||
Host: models.HTML(g.Host()),
|
||||
Provider: "<a href=\"https://godaddy.com\">GoDaddy</a>",
|
||||
IPVersion: models.HTML(g.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *godaddy) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
type goDaddyPutBody struct {
|
||||
Data string `json:"data"` // IP address to update to
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.godaddy.com",
|
||||
Path: fmt.Sprintf("/v1/domains/%s/records/A/%s", g.domain, g.host),
|
||||
}
|
||||
r, err := network.BuildHTTPPut(u.String(), []goDaddyPutBody{{ip.String()}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
r.Header.Set("Authorization", "sso-key "+g.key+":"+g.secret)
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
var parsedJSON struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||
return nil, err
|
||||
} else if len(parsedJSON.Message) > 0 {
|
||||
return nil, fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
155
internal/settings/infomaniak.go
Normal file
155
internal/settings/infomaniak.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type infomaniak struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
username string
|
||||
password string
|
||||
useProviderIP bool
|
||||
}
|
||||
|
||||
func NewInfomaniak(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
UseProviderIP bool `json:"provider_ip"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i := &infomaniak{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
username: extraSettings.Username,
|
||||
password: extraSettings.Password,
|
||||
useProviderIP: extraSettings.UseProviderIP,
|
||||
}
|
||||
if err := i.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *infomaniak) isValid() error {
|
||||
switch {
|
||||
case len(i.username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(i.password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case i.host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *infomaniak) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Infomaniak]", i.domain, i.host)
|
||||
}
|
||||
|
||||
func (i *infomaniak) Domain() string {
|
||||
return i.domain
|
||||
}
|
||||
|
||||
func (i *infomaniak) Host() string {
|
||||
return i.host
|
||||
}
|
||||
|
||||
func (i *infomaniak) IPVersion() models.IPVersion {
|
||||
return i.ipVersion
|
||||
}
|
||||
|
||||
func (i *infomaniak) DNSLookup() bool {
|
||||
return i.dnsLookup
|
||||
}
|
||||
|
||||
func (i *infomaniak) BuildDomainName() string {
|
||||
return buildDomainName(i.host, i.domain)
|
||||
}
|
||||
|
||||
func (i *infomaniak) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", i.BuildDomainName(), i.BuildDomainName())),
|
||||
Host: models.HTML(i.Host()),
|
||||
Provider: "<a href=\"https://www.infomaniak.com/\">Infomaniak</a>",
|
||||
IPVersion: models.HTML(i.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *infomaniak) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "infomaniak.com",
|
||||
Path: "/nic/update",
|
||||
User: url.UserPassword(i.username, i.password),
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("hostname", i.domain)
|
||||
if i.host != "@" {
|
||||
values.Set("hostname", i.host+"."+i.domain)
|
||||
}
|
||||
if !i.useProviderIP {
|
||||
values.Set("myip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(content)
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
switch {
|
||||
case strings.HasPrefix(s, "good "):
|
||||
newIP = net.ParseIP(s[5:])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("no received IP in response %q", s)
|
||||
} else if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
|
||||
}
|
||||
return newIP, nil
|
||||
case strings.HasPrefix(s, "nochg "):
|
||||
newIP = net.ParseIP(s[6:])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("no received IP in response %q", s)
|
||||
} else if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
|
||||
}
|
||||
return newIP, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ok status but unknown response %q", s)
|
||||
}
|
||||
case http.StatusBadRequest:
|
||||
switch s {
|
||||
case "nohost":
|
||||
return nil, fmt.Errorf("infomaniak.com: host %q does not exist for domain %q", i.host, i.domain)
|
||||
case "badauth":
|
||||
return nil, fmt.Errorf("infomaniak.com: bad authentication")
|
||||
default:
|
||||
return nil, fmt.Errorf("infomaniak.com: bad request: %s", s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("received status %d with message: %s", status, s)
|
||||
}
|
||||
}
|
||||
133
internal/settings/namecheap.go
Normal file
133
internal/settings/namecheap.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type namecheap struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
password string
|
||||
useProviderIP bool
|
||||
}
|
||||
|
||||
func NewNamecheap(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Password string `json:"password"`
|
||||
UseProviderIP bool `json:"provider_ip"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &namecheap{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
password: extraSettings.Password,
|
||||
useProviderIP: extraSettings.UseProviderIP,
|
||||
}
|
||||
if err := n.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (n *namecheap) isValid() error {
|
||||
if !constants.MatchNamecheapPassword(n.password) {
|
||||
return fmt.Errorf("invalid password format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *namecheap) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Namecheap]", n.domain, n.host)
|
||||
}
|
||||
|
||||
func (n *namecheap) Domain() string {
|
||||
return n.domain
|
||||
}
|
||||
|
||||
func (n *namecheap) Host() string {
|
||||
return n.host
|
||||
}
|
||||
|
||||
func (n *namecheap) IPVersion() models.IPVersion {
|
||||
return n.ipVersion
|
||||
}
|
||||
|
||||
func (n *namecheap) DNSLookup() bool {
|
||||
return n.dnsLookup
|
||||
}
|
||||
|
||||
func (n *namecheap) BuildDomainName() string {
|
||||
return buildDomainName(n.host, n.domain)
|
||||
}
|
||||
|
||||
func (n *namecheap) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", n.BuildDomainName(), n.BuildDomainName())),
|
||||
Host: models.HTML(n.Host()),
|
||||
Provider: "<a href=\"https://namecheap.com\">Namecheap</a>",
|
||||
IPVersion: models.HTML(n.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *namecheap) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dynamicdns.park-your-domain.com",
|
||||
Path: "/update",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("host", n.host)
|
||||
values.Set("domain", n.domain)
|
||||
values.Set("password", n.password)
|
||||
if !n.useProviderIP {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var parsedXML struct {
|
||||
Errors struct {
|
||||
Error string `xml:"Err1"`
|
||||
} `xml:"errors"`
|
||||
IP string `xml:"IP"`
|
||||
}
|
||||
err = xml.Unmarshal(content, &parsedXML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if parsedXML.Errors.Error != "" {
|
||||
return nil, fmt.Errorf(parsedXML.Errors.Error)
|
||||
}
|
||||
newIP = net.ParseIP(parsedXML.IP)
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
|
||||
}
|
||||
if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
}
|
||||
152
internal/settings/noip.go
Normal file
152
internal/settings/noip.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
netlib "github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
//nolint:maligned
|
||||
type noip struct {
|
||||
domain string
|
||||
host string
|
||||
ipVersion models.IPVersion
|
||||
dnsLookup bool
|
||||
username string
|
||||
password string
|
||||
useProviderIP bool
|
||||
}
|
||||
|
||||
func NewNoip(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error) {
|
||||
extraSettings := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
UseProviderIP bool `json:"provider_ip"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extraSettings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &noip{
|
||||
domain: domain,
|
||||
host: host,
|
||||
ipVersion: ipVersion,
|
||||
dnsLookup: !noDNSLookup,
|
||||
username: extraSettings.Username,
|
||||
password: extraSettings.Password,
|
||||
useProviderIP: extraSettings.UseProviderIP,
|
||||
}
|
||||
if err := n.isValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (n *noip) isValid() error {
|
||||
switch {
|
||||
case len(n.username) == 0:
|
||||
return fmt.Errorf("username cannot be empty")
|
||||
case len(n.username) > 50:
|
||||
return fmt.Errorf("username cannot be longer than 50 characters")
|
||||
case len(n.password) == 0:
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
case n.host == "*":
|
||||
return fmt.Errorf(`host cannot be "*"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noip) String() string {
|
||||
return fmt.Sprintf("[domain: %s | host: %s | provider: Noip]", n.domain, n.host)
|
||||
}
|
||||
|
||||
func (n *noip) Domain() string {
|
||||
return n.domain
|
||||
}
|
||||
|
||||
func (n *noip) Host() string {
|
||||
return n.host
|
||||
}
|
||||
|
||||
func (n *noip) DNSLookup() bool {
|
||||
return n.dnsLookup
|
||||
}
|
||||
|
||||
func (n *noip) IPVersion() models.IPVersion {
|
||||
return n.ipVersion
|
||||
}
|
||||
|
||||
func (n *noip) BuildDomainName() string {
|
||||
return buildDomainName(n.host, n.domain)
|
||||
}
|
||||
|
||||
func (n *noip) HTML() models.HTMLRow {
|
||||
return models.HTMLRow{
|
||||
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", n.BuildDomainName(), n.BuildDomainName())),
|
||||
Host: models.HTML(n.Host()),
|
||||
Provider: "<a href=\"https://www.noip.com/\">NoIP</a>",
|
||||
IPVersion: models.HTML(n.ipVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *noip) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dynupdate.no-ip.com",
|
||||
Path: "/nic/update",
|
||||
User: url.UserPassword(n.username, n.password),
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("hostname", n.BuildDomainName())
|
||||
if !n.useProviderIP {
|
||||
values.Set("myip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(content)
|
||||
switch s {
|
||||
case "":
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
case "911":
|
||||
return nil, fmt.Errorf("NoIP's internal server error 911")
|
||||
case "abuse":
|
||||
return nil, fmt.Errorf("username is banned due to abuse")
|
||||
case "!donator":
|
||||
return nil, fmt.Errorf("user has not this extra feature")
|
||||
case "badagent":
|
||||
return nil, fmt.Errorf("user agent is banned")
|
||||
case "badauth":
|
||||
return nil, fmt.Errorf("invalid username password combination")
|
||||
case "nohost":
|
||||
return nil, fmt.Errorf("hostname does not exist")
|
||||
}
|
||||
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
|
||||
ips := verification.NewVerifier().SearchIPv4(s)
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no IP address in response")
|
||||
}
|
||||
newIP = net.ParseIP(ips[0])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||
}
|
||||
if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response %q", s)
|
||||
}
|
||||
33
internal/settings/settings.go
Normal file
33
internal/settings/settings.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
type Settings interface {
|
||||
String() string
|
||||
Domain() string
|
||||
Host() string
|
||||
BuildDomainName() string
|
||||
HTML() models.HTMLRow
|
||||
DNSLookup() bool
|
||||
IPVersion() models.IPVersion
|
||||
Update(client network.Client, ip net.IP) (newIP net.IP, err error)
|
||||
}
|
||||
|
||||
type Constructor func(data json.RawMessage, domain string, host string, ipVersion models.IPVersion, noDNSLookup bool) (s Settings, err error)
|
||||
|
||||
func buildDomainName(host, domain string) string {
|
||||
switch host {
|
||||
case "@":
|
||||
return domain
|
||||
case "*":
|
||||
return "any." + domain
|
||||
default:
|
||||
return host + "." + domain
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package trigger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/update"
|
||||
)
|
||||
|
||||
// StartUpdates starts periodic updates
|
||||
func StartUpdates(ctx context.Context, updater update.Updater, idPeriodMapping map[int]time.Duration, onError func(err error)) (forceUpdate func()) {
|
||||
errors := make(chan error)
|
||||
triggers := make([]chan struct{}, len(idPeriodMapping))
|
||||
for id, period := range idPeriodMapping {
|
||||
triggers[id] = make(chan struct{})
|
||||
go func(id int, period time.Duration) {
|
||||
ticker := time.NewTicker(period)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-triggers[id]:
|
||||
if err := updater.Update(id); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
case <-ticker.C:
|
||||
if err := updater.Update(id); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(id, period)
|
||||
}
|
||||
// collects errors only
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case err := <-errors:
|
||||
onError(err)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
for i := range triggers {
|
||||
triggers[i] <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
libnetwork "github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host, email, key, userServiceKey, token string, proxied bool, ttl uint, ip net.IP) (err error) {
|
||||
if ip == nil {
|
||||
return fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
type cloudflarePutBody struct {
|
||||
Type string `json:"type"` // forced to A
|
||||
Name string `json:"name"` // DNS record name i.e. example.com
|
||||
Content string `json:"content"` // ip address
|
||||
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
|
||||
TTL uint `json:"ttl"`
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.cloudflare.com",
|
||||
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", zoneIdentifier, identifier),
|
||||
}
|
||||
r, err := network.BuildHTTPPut(
|
||||
u.String(),
|
||||
cloudflarePutBody{
|
||||
Type: "A",
|
||||
Name: host,
|
||||
Content: ip.String(),
|
||||
Proxied: proxied,
|
||||
TTL: ttl,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
switch {
|
||||
case len(token) > 0:
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
case len(userServiceKey) > 0:
|
||||
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
|
||||
case len(email) > 0 && len(key) > 0:
|
||||
r.Header.Set("X-Auth-Email", email)
|
||||
r.Header.Set("X-Auth-Key", key)
|
||||
default:
|
||||
return fmt.Errorf("email and key are both unset and user service key is not set and no token was provided")
|
||||
}
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if status > http.StatusUnsupportedMediaType {
|
||||
return fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var parsedJSON struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Result struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||
return err
|
||||
} else if !parsedJSON.Success {
|
||||
var errStr string
|
||||
for _, e := range parsedJSON.Errors {
|
||||
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
|
||||
}
|
||||
return fmt.Errorf(errStr)
|
||||
}
|
||||
newIP := net.ParseIP(parsedJSON.Result.Content)
|
||||
if newIP == nil {
|
||||
return fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
|
||||
} else if !newIP.Equal(ip) {
|
||||
return fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
internal/update/cycle.go
Normal file
34
internal/update/cycle.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
)
|
||||
|
||||
type cycler interface {
|
||||
next() models.IPMethod
|
||||
}
|
||||
|
||||
type cyclerImpl struct {
|
||||
sync.Mutex
|
||||
counter int
|
||||
methods []models.IPMethod
|
||||
}
|
||||
|
||||
func newCycler(methods []models.IPMethod) cycler {
|
||||
return &cyclerImpl{
|
||||
methods: methods,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cyclerImpl) next() models.IPMethod {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
method := c.methods[c.counter]
|
||||
c.counter++
|
||||
if c.counter == len(c.methods) {
|
||||
c.counter = 0
|
||||
}
|
||||
return method
|
||||
}
|
||||
64
internal/update/cycle_test.go
Normal file
64
internal/update/cycle_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_newCycler(t *testing.T) {
|
||||
t.Parallel()
|
||||
ipMethods := []models.IPMethod{
|
||||
{Name: "a"}, {Name: "b"},
|
||||
}
|
||||
c := newCycler(ipMethods)
|
||||
require.NotNil(t, c)
|
||||
ipMethod := c.next()
|
||||
assert.Equal(t, ipMethod, models.IPMethod{Name: "a"})
|
||||
}
|
||||
|
||||
func Test_next(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cyclerImpl{
|
||||
methods: []models.IPMethod{
|
||||
{Name: "a"}, {Name: "b"},
|
||||
},
|
||||
}
|
||||
var m models.IPMethod
|
||||
m = c.next()
|
||||
assert.Equal(t, m, models.IPMethod{Name: "a"})
|
||||
m = c.next()
|
||||
assert.Equal(t, m, models.IPMethod{Name: "b"})
|
||||
m = c.next()
|
||||
assert.Equal(t, m, models.IPMethod{Name: "a"})
|
||||
}
|
||||
|
||||
func Test_next_RaceCondition(t *testing.T) {
|
||||
// Run with -race flag
|
||||
t.Parallel()
|
||||
const workers = 5
|
||||
const loopSize = 101
|
||||
c := &cyclerImpl{
|
||||
methods: []models.IPMethod{
|
||||
{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
|
||||
},
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
wg := &sync.WaitGroup{}
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
<-ready
|
||||
for i := 0; i < loopSize; i++ {
|
||||
c.next()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
close(ready)
|
||||
wg.Wait()
|
||||
assert.Equal(t, (workers*loopSize)%len(c.methods), c.counter)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateDDNSS(client network.Client, domain, host, username, password string, ip net.IP) error {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.ddnss.de",
|
||||
Path: "/upd.php",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("user", username)
|
||||
values.Set("pwd", password)
|
||||
fqdn := domain
|
||||
if host != "@" {
|
||||
fqdn = host + "." + domain
|
||||
}
|
||||
values.Set("host", fqdn)
|
||||
if ip != nil {
|
||||
if ip.To4() == nil { // ipv6
|
||||
values.Set("ip6", ip.String())
|
||||
} else {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := string(content)
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("received status %d with message: %s", status, s)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(s, "badysys"):
|
||||
return fmt.Errorf("ddnss.de: invalid system parameter")
|
||||
case strings.Contains(s, "badauth"):
|
||||
return fmt.Errorf("ddnss.de: bad authentication")
|
||||
case strings.Contains(s, "notfqdn"):
|
||||
return fmt.Errorf("ddnss.de: hostname %q does not exist", fqdn)
|
||||
case strings.Contains(s, "Updated 1 hostname"):
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown response received from ddnss.de: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateDNSPod(client network.Client, domain, host, token string, ip net.IP) (err error) {
|
||||
if ip == nil {
|
||||
return fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dnsapi.cn",
|
||||
Path: "/Record.List",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("login_token", token)
|
||||
values.Set("format", "json")
|
||||
values.Set("domain", domain)
|
||||
values.Set("length", "200")
|
||||
values.Set("sub_domain", host)
|
||||
values.Set("record_type", "A")
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if status != http.StatusOK {
|
||||
return fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var recordResp struct {
|
||||
Records []struct {
|
||||
ID string `json:"id"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Line string `json:"line"`
|
||||
} `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &recordResp); err != nil {
|
||||
return err
|
||||
}
|
||||
var recordID, recordLine string
|
||||
for _, record := range recordResp.Records {
|
||||
if record.Type == "A" && record.Name == host {
|
||||
receivedIP := net.ParseIP(record.Value)
|
||||
if ip.Equal(receivedIP) {
|
||||
return nil
|
||||
}
|
||||
recordID = record.ID
|
||||
recordLine = record.Line
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(recordID) == 0 {
|
||||
return fmt.Errorf("record not found")
|
||||
}
|
||||
|
||||
u.Path = "/Record.Ddns"
|
||||
values = url.Values{}
|
||||
values.Set("login_token", token)
|
||||
values.Set("format", "json")
|
||||
values.Set("domain", domain)
|
||||
values.Set("record_id", recordID)
|
||||
values.Set("value", ip.String())
|
||||
values.Set("record_line", recordLine)
|
||||
values.Set("sub_domain", host)
|
||||
u.RawQuery = values.Encode()
|
||||
r, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBufferString(values.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err = client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if status != http.StatusOK {
|
||||
return fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var ddnsResp struct {
|
||||
Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
} `json:"record"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &ddnsResp); err != nil {
|
||||
return err
|
||||
}
|
||||
receivedIP := net.ParseIP(ddnsResp.Record.Value)
|
||||
if !ip.Equal(receivedIP) {
|
||||
return fmt.Errorf("ip not set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
func updateDuckDNS(client network.Client, domain, token string, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.duckdns.org",
|
||||
Path: "/update",
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("verbose", "true")
|
||||
values.Set("domains", domain)
|
||||
values.Set("token", token)
|
||||
u.RawQuery = values.Encode()
|
||||
if ip != nil {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
s := string(content)
|
||||
switch {
|
||||
case len(s) < 2:
|
||||
return nil, fmt.Errorf("response %q is too short", s)
|
||||
case s[0:2] == "KO":
|
||||
return nil, fmt.Errorf("invalid domain token combination")
|
||||
case s[0:2] == "OK":
|
||||
ips := verification.NewVerifier().SearchIPv4(s)
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no IP address in response")
|
||||
}
|
||||
newIP = net.ParseIP(ips[0])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||
}
|
||||
if ip != nil && !newIP.Equal(ip) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid response %q", s)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
libnetwork "github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, ip net.IP) error {
|
||||
if ip == nil {
|
||||
return fmt.Errorf("IP address was not given to updater")
|
||||
}
|
||||
type goDaddyPutBody struct {
|
||||
Data string `json:"data"` // IP address to update to
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.godaddy.com",
|
||||
Path: fmt.Sprintf("/v1/domains/%s/records/A/%s", domain, host),
|
||||
}
|
||||
r, err := network.BuildHTTPPut(u.String(), []goDaddyPutBody{{ip.String()}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if status != http.StatusOK {
|
||||
var parsedJSON struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||
return err
|
||||
} else if len(parsedJSON.Message) > 0 {
|
||||
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
|
||||
}
|
||||
return fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateInfomaniak(client network.Client, domain, host, username, password string, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "infomaniak.com",
|
||||
Path: "/nic/update",
|
||||
User: url.UserPassword(username, password),
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("hostname", domain)
|
||||
if host != "@" {
|
||||
values.Set("hostname", host+"."+domain)
|
||||
}
|
||||
if ip != nil {
|
||||
values.Set("myip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(content)
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
switch {
|
||||
case strings.HasPrefix(s, "good "):
|
||||
newIP = net.ParseIP(s[5:])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("no received IP in response %q", s)
|
||||
} else if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
|
||||
}
|
||||
return newIP, nil
|
||||
case strings.HasPrefix(s, "nochg "):
|
||||
newIP = net.ParseIP(s[6:])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("no received IP in response %q", s)
|
||||
} else if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("received IP %s is not equal to expected IP %s", newIP, ip)
|
||||
}
|
||||
return newIP, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ok status but unknown response %q", s)
|
||||
}
|
||||
case http.StatusBadRequest:
|
||||
switch s {
|
||||
case "nohost":
|
||||
return nil, fmt.Errorf("infomaniak.com: host %q does not exist for domain %q", host, domain)
|
||||
case "badauth":
|
||||
return nil, fmt.Errorf("infomaniak.com: bad authentication")
|
||||
default:
|
||||
return nil, fmt.Errorf("infomaniak.com: bad request: %s", s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("received status %d with message: %s", status, s)
|
||||
}
|
||||
}
|
||||
77
internal/update/ip.go
Normal file
77
internal/update/ip.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
libnet "github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
const cycle = "cycle"
|
||||
|
||||
type IPGetter interface {
|
||||
IP() (ip net.IP, err error)
|
||||
IPv4() (ip net.IP, err error)
|
||||
IPv6() (ip net.IP, err error)
|
||||
}
|
||||
|
||||
type ipGetter struct {
|
||||
client libnet.Client
|
||||
ipMethod models.IPMethod
|
||||
ipv4Method models.IPMethod
|
||||
ipv6Method models.IPMethod
|
||||
cyclerIP cycler
|
||||
cyclerIPv4 cycler
|
||||
cyclerIPv6 cycler
|
||||
}
|
||||
|
||||
func NewIPGetter(client libnet.Client, ipMethod, ipv4Method, ipv6Method models.IPMethod) IPGetter {
|
||||
ipMethods := []models.IPMethod{}
|
||||
ipv4Methods := []models.IPMethod{}
|
||||
ipv6Methods := []models.IPMethod{}
|
||||
for _, method := range constants.IPMethods() {
|
||||
switch {
|
||||
case method.IPv4 && method.IPv6:
|
||||
ipMethods = append(ipMethods, method)
|
||||
case method.IPv4:
|
||||
ipv4Methods = append(ipv4Methods, method)
|
||||
case method.IPv6:
|
||||
ipv6Methods = append(ipv6Methods, method)
|
||||
}
|
||||
}
|
||||
return &ipGetter{
|
||||
client: client,
|
||||
ipMethod: ipMethod,
|
||||
ipv4Method: ipv4Method,
|
||||
ipv6Method: ipv6Method,
|
||||
cyclerIP: newCycler(ipMethods),
|
||||
cyclerIPv4: newCycler(ipv4Methods),
|
||||
cyclerIPv6: newCycler(ipv6Methods),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ipGetter) IP() (ip net.IP, err error) {
|
||||
method := i.ipMethod
|
||||
if method.Name == cycle {
|
||||
method = i.cyclerIP.next()
|
||||
}
|
||||
return network.GetPublicIP(i.client, method.URL, constants.IPv4OrIPv6)
|
||||
}
|
||||
|
||||
func (i *ipGetter) IPv4() (ip net.IP, err error) {
|
||||
method := i.ipv4Method
|
||||
if method.Name == cycle {
|
||||
method = i.cyclerIPv4.next()
|
||||
}
|
||||
return network.GetPublicIP(i.client, method.URL, constants.IPv4)
|
||||
}
|
||||
|
||||
func (i *ipGetter) IPv6() (ip net.IP, err error) {
|
||||
method := i.ipv6Method
|
||||
if method.Name == cycle {
|
||||
method = i.cyclerIPv6.next()
|
||||
}
|
||||
return network.GetPublicIP(i.client, method.URL, constants.IPv6)
|
||||
}
|
||||
143
internal/update/ip_test.go
Normal file
143
internal/update/ip_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/network/mock_network"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_NewIPGetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := network.NewClient(time.Second)
|
||||
ipMethod := models.IPMethod{Name: "ip"}
|
||||
ipv4Method := models.IPMethod{Name: "ipv4"}
|
||||
ipv6Method := models.IPMethod{Name: "ipv6"}
|
||||
ipGetter := NewIPGetter(client, ipMethod, ipv4Method, ipv6Method)
|
||||
assert.NotNil(t, ipGetter)
|
||||
}
|
||||
|
||||
func Test_IP(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
ipMethod models.IPMethod
|
||||
mockContent []byte
|
||||
ip net.IP
|
||||
}{
|
||||
"url ipv4": {
|
||||
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
"url ipv6": {
|
||||
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
|
||||
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
|
||||
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
"cycle": {
|
||||
ipMethod: models.IPMethod{Name: cycle},
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockCtrl := gomock.NewController(t)
|
||||
defer mockCtrl.Finish()
|
||||
client := mock_network.NewMockClient(mockCtrl)
|
||||
url := tc.ipMethod.URL
|
||||
if tc.ipMethod.Name == cycle {
|
||||
url = "https://diagnostic.opendns.com/myip"
|
||||
}
|
||||
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
|
||||
ig := NewIPGetter(client, tc.ipMethod, models.IPMethod{}, models.IPMethod{})
|
||||
ip, err := ig.IP()
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, tc.ip.Equal(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_IPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
ipMethod models.IPMethod
|
||||
mockContent []byte
|
||||
ip net.IP
|
||||
}{
|
||||
"url": {
|
||||
ipMethod: models.IPMethod{URL: "https://opendns.com/ip"},
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
"cycle": {
|
||||
ipMethod: models.IPMethod{Name: cycle},
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockCtrl := gomock.NewController(t)
|
||||
defer mockCtrl.Finish()
|
||||
client := mock_network.NewMockClient(mockCtrl)
|
||||
url := tc.ipMethod.URL
|
||||
if tc.ipMethod.Name == cycle {
|
||||
url = "https://api.ipify.org"
|
||||
}
|
||||
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
|
||||
ig := NewIPGetter(client, models.IPMethod{}, tc.ipMethod, models.IPMethod{})
|
||||
ip, err := ig.IPv4()
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, tc.ip.Equal(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_IPv6(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
ipMethod models.IPMethod
|
||||
mockContent []byte
|
||||
ip net.IP
|
||||
}{
|
||||
"url": {
|
||||
ipMethod: models.IPMethod{URL: "https://ip6.ddnss.de/meineip.php"},
|
||||
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
|
||||
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
"cycle": {
|
||||
ipMethod: models.IPMethod{Name: cycle},
|
||||
mockContent: []byte("blabla ad07:e846:51ac:6cd0:0000:0000:0000:0000 sds"),
|
||||
ip: net.IP{0xad, 0x7, 0xe8, 0x46, 0x51, 0xac, 0x6c, 0xd0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockCtrl := gomock.NewController(t)
|
||||
defer mockCtrl.Finish()
|
||||
client := mock_network.NewMockClient(mockCtrl)
|
||||
url := tc.ipMethod.URL
|
||||
if tc.ipMethod.Name == cycle {
|
||||
url = "https://api6.ipify.org"
|
||||
}
|
||||
client.EXPECT().GetContent(url).Return(tc.mockContent, http.StatusOK, nil).Times(1)
|
||||
ig := NewIPGetter(client, models.IPMethod{}, models.IPMethod{}, tc.ipMethod)
|
||||
ip, err := ig.IPv6()
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, tc.ip.Equal(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
)
|
||||
|
||||
func updateNamecheap(client network.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dynamicdns.park-your-domain.com",
|
||||
Path: "/update",
|
||||
// User: url.UserPassword(username, password),
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("host", host)
|
||||
values.Set("domain", domain)
|
||||
values.Set("password", password)
|
||||
if ip != nil {
|
||||
values.Set("ip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
}
|
||||
var parsedXML struct {
|
||||
Errors struct {
|
||||
Error string `xml:"Err1"`
|
||||
} `xml:"errors"`
|
||||
IP string `xml:"IP"`
|
||||
}
|
||||
err = xml.Unmarshal(content, &parsedXML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if parsedXML.Errors.Error != "" {
|
||||
return nil, fmt.Errorf(parsedXML.Errors.Error)
|
||||
}
|
||||
newIP = net.ParseIP(parsedXML.IP)
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
|
||||
}
|
||||
if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
)
|
||||
|
||||
func updateNoIP(client network.Client, hostname, username, password string, ip net.IP) (newIP net.IP, err error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "dynupdate.no-ip.com",
|
||||
Path: "/nic/update",
|
||||
User: url.UserPassword(username, password),
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("hostname", hostname)
|
||||
if ip != nil {
|
||||
values.Set("myip", ip.String())
|
||||
}
|
||||
u.RawQuery = values.Encode()
|
||||
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||
status, content, err := client.DoHTTPRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(content)
|
||||
switch s {
|
||||
case "":
|
||||
return nil, fmt.Errorf("HTTP status %d", status)
|
||||
case "911":
|
||||
return nil, fmt.Errorf("NoIP's internal server error 911")
|
||||
case "abuse":
|
||||
return nil, fmt.Errorf("username is banned due to abuse")
|
||||
case "!donator":
|
||||
return nil, fmt.Errorf("user has not this extra feature")
|
||||
case "badagent":
|
||||
return nil, fmt.Errorf("user agent is banned")
|
||||
case "badauth":
|
||||
return nil, fmt.Errorf("invalid username password combination")
|
||||
case "nohost":
|
||||
return nil, fmt.Errorf("hostname does not exist")
|
||||
}
|
||||
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
|
||||
ips := verification.NewVerifier().SearchIPv4(s)
|
||||
if ips == nil {
|
||||
return nil, fmt.Errorf("no IP address in response")
|
||||
}
|
||||
newIP = net.ParseIP(ips[0])
|
||||
if newIP == nil {
|
||||
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||
}
|
||||
if ip != nil && !ip.Equal(newIP) {
|
||||
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||
}
|
||||
return newIP, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response %q", s)
|
||||
}
|
||||
155
internal/update/run.go
Normal file
155
internal/update/run.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
librecords "github.com/qdm12/ddns-updater/internal/records"
|
||||
"github.com/qdm12/golibs/logging"
|
||||
)
|
||||
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, period time.Duration, records []librecords.Record) (forceUpdate func())
|
||||
}
|
||||
|
||||
type runner struct {
|
||||
updater Updater
|
||||
ipGetter IPGetter
|
||||
logger logging.Logger
|
||||
timeNow func() time.Time
|
||||
}
|
||||
|
||||
func NewRunner(updater Updater, ipGetter IPGetter, logger logging.Logger, timeNow func() time.Time) Runner {
|
||||
return &runner{
|
||||
updater: updater,
|
||||
ipGetter: ipGetter,
|
||||
logger: logger,
|
||||
timeNow: timeNow,
|
||||
}
|
||||
}
|
||||
|
||||
func readPersistedIPs(records []librecords.Record) (ip, ipv4, ipv6 net.IP) {
|
||||
for _, record := range records {
|
||||
switch record.Settings.IPVersion() {
|
||||
case constants.IPv4OrIPv6:
|
||||
ip = record.History.GetCurrentIP()
|
||||
if ip == nil {
|
||||
ip = net.IP{127, 0, 0, 1}
|
||||
}
|
||||
case constants.IPv4:
|
||||
ipv4 = record.History.GetCurrentIP()
|
||||
if ipv4 == nil {
|
||||
ipv4 = net.IP{127, 0, 0, 1}
|
||||
}
|
||||
case constants.IPv6:
|
||||
ipv6 = record.History.GetCurrentIP()
|
||||
if ipv6 == nil {
|
||||
ipv6 = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
}
|
||||
}
|
||||
if ip != nil && ipv4 != nil && ipv6 != nil {
|
||||
return ip, ipv4, ipv6
|
||||
}
|
||||
}
|
||||
return ip, ipv4, ipv6
|
||||
}
|
||||
|
||||
func (r *runner) getNewIPs(doIP, doIPv4, doIPv6 bool) (ip, ipv4, ipv6 net.IP, errors []error) {
|
||||
var err error
|
||||
if doIP {
|
||||
ip, err = r.ipGetter.IP()
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
if doIPv4 {
|
||||
ipv4, err = r.ipGetter.IPv4()
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
if doIPv6 {
|
||||
ipv6, err = r.ipGetter.IPv6()
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
return ip, ipv4, ipv6, errors
|
||||
}
|
||||
|
||||
func shouldUpdate(ip, newIP net.IP, force bool) bool {
|
||||
ipVersionDisabled := ip == nil
|
||||
ipFetchFailed := newIP == nil
|
||||
ipChanged := !ip.Equal(newIP)
|
||||
switch {
|
||||
case ipVersionDisabled, ipFetchFailed:
|
||||
return false
|
||||
case ipChanged, force:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) updateNecessary(records []librecords.Record, ip, ipv4, ipv6 net.IP, force bool) (newIP, newIPv4, newIPv6 net.IP) {
|
||||
newIP, newIPv4, newIPv6, errors := r.getNewIPs(ip != nil, ipv4 != nil, ipv6 != nil)
|
||||
for _, err := range errors {
|
||||
r.logger.Error(err)
|
||||
}
|
||||
updateIP := shouldUpdate(ip, newIP, force)
|
||||
updateIPv4 := shouldUpdate(ipv4, newIPv4, force)
|
||||
updateIPv6 := shouldUpdate(ipv6, newIPv6, force)
|
||||
if updateIP && !force {
|
||||
r.logger.Info("IP address changed from %s to %s", ip, newIP)
|
||||
}
|
||||
if updateIPv4 && !force {
|
||||
r.logger.Info("IPv4 address changed from %s to %s", ipv4, newIPv4)
|
||||
}
|
||||
if updateIPv6 && !force {
|
||||
r.logger.Info("IPv6 address changed from %s to %s", ipv6, newIPv6)
|
||||
}
|
||||
for id, record := range records {
|
||||
now := r.timeNow()
|
||||
var err error
|
||||
switch {
|
||||
case updateIP && record.Settings.IPVersion() == constants.IPv4OrIPv6:
|
||||
err = r.updater.Update(id, newIP, now)
|
||||
case updateIPv4 && record.Settings.IPVersion() == constants.IPv4:
|
||||
err = r.updater.Update(id, newIPv4, now)
|
||||
case updateIPv6 && record.Settings.IPVersion() == constants.IPv6:
|
||||
err = r.updater.Update(id, newIPv6, now)
|
||||
}
|
||||
if err != nil {
|
||||
r.logger.Error(err)
|
||||
}
|
||||
}
|
||||
return newIP, newIPv4, newIPv6
|
||||
}
|
||||
|
||||
func (r *runner) Run(ctx context.Context, period time.Duration, records []librecords.Record) (forceUpdate func()) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
timer := time.NewTicker(period)
|
||||
forceChannel := make(chan struct{})
|
||||
go func() {
|
||||
r.logger.Info("reading persisted ips")
|
||||
ip, ipv4, ipv6 := readPersistedIPs(records)
|
||||
r.logger.Info("done reading persisted ips")
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
ip, ipv4, ipv6 = r.updateNecessary(records, ip, ipv4, ipv6, false)
|
||||
case <-forceChannel:
|
||||
ip, ipv4, ipv6 = r.updateNecessary(records, ip, ipv4, ipv6, true)
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
forceChannel <- struct{}{}
|
||||
}
|
||||
}
|
||||
@@ -3,212 +3,71 @@ package update
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/golibs/logging"
|
||||
libnetwork "github.com/qdm12/golibs/network"
|
||||
"github.com/qdm12/golibs/verification"
|
||||
netlib "github.com/qdm12/golibs/network"
|
||||
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/data"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/ddns-updater/internal/network"
|
||||
)
|
||||
|
||||
type Updater interface {
|
||||
Update(id int) error
|
||||
Update(id int, ip net.IP, now time.Time) (err error)
|
||||
}
|
||||
|
||||
type updater struct {
|
||||
db data.Database
|
||||
logger logging.Logger
|
||||
client libnetwork.Client
|
||||
notify notifyFunc
|
||||
verifier verification.Verifier
|
||||
ipMethods []models.IPMethod
|
||||
counter int
|
||||
counterMutex sync.RWMutex
|
||||
db data.Database
|
||||
logger logging.Logger
|
||||
client netlib.Client
|
||||
notify notifyFunc
|
||||
}
|
||||
|
||||
type notifyFunc func(priority int, messageArgs ...interface{})
|
||||
|
||||
func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
|
||||
func NewUpdater(db data.Database, logger logging.Logger, client netlib.Client, notify notifyFunc) Updater {
|
||||
return &updater{
|
||||
db: db,
|
||||
logger: logger,
|
||||
client: client,
|
||||
notify: notify,
|
||||
verifier: verification.NewVerifier(),
|
||||
ipMethods: constants.IPMethodExternalChoices(),
|
||||
db: db,
|
||||
logger: logger,
|
||||
client: client,
|
||||
notify: notify,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *updater) Update(id int) error {
|
||||
func (u *updater) Update(id int, ip net.IP, now time.Time) (err error) {
|
||||
record, err := u.db.Select(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.Time = time.Now()
|
||||
record.Time = now
|
||||
record.Status = constants.UPDATING
|
||||
if err := u.db.Update(id, record); err != nil {
|
||||
return err
|
||||
}
|
||||
status, message, newIP, err := u.update(
|
||||
record.Settings,
|
||||
record.History.GetCurrentIP(),
|
||||
record.History.GetDurationSinceSuccess(time.Now()))
|
||||
record.Status = status
|
||||
record.Message = message
|
||||
if err != nil {
|
||||
if len(record.Message) == 0 {
|
||||
var newIP net.IP
|
||||
record.Status = constants.FAIL
|
||||
if ip.Equal(record.History.GetCurrentIP()) {
|
||||
record.Status = constants.UPTODATE
|
||||
record.Message = fmt.Sprintf("No IP change for %s", record.History.GetDurationSinceSuccess(now))
|
||||
} else {
|
||||
newIP, err = record.Settings.Update(u.client, ip)
|
||||
if err != nil {
|
||||
record.Message = err.Error()
|
||||
if updateErr := u.db.Update(id, record); updateErr != nil {
|
||||
return fmt.Errorf("%s, %s", err, updateErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if updateErr := u.db.Update(id, record); updateErr != nil {
|
||||
return fmt.Errorf("%s, %s", err, updateErr)
|
||||
}
|
||||
return err
|
||||
record.Status = constants.SUCCESS
|
||||
record.Message = fmt.Sprintf("changed to %s", ip.String())
|
||||
}
|
||||
if newIP != nil {
|
||||
record.History = append(record.History, models.HistoryEvent{
|
||||
IP: newIP,
|
||||
Time: time.Now(),
|
||||
Time: now,
|
||||
})
|
||||
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), message))
|
||||
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), record.Message))
|
||||
}
|
||||
return u.db.Update(id, record) // persists some data if needed (i.e new IP)
|
||||
}
|
||||
|
||||
func (u *updater) update(settings models.Settings, currentIP net.IP, durationSinceSuccess string) (status models.Status, message string, newIP net.IP, err error) {
|
||||
// Get the public IP address
|
||||
ip, err := u.getPublicIP(settings.IPMethod, settings.IPVersion) // Note: empty IP means DNS provider provided
|
||||
if err != nil {
|
||||
return constants.FAIL, "", nil, err
|
||||
}
|
||||
if ip != nil && ip.Equal(currentIP) {
|
||||
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
|
||||
}
|
||||
|
||||
// Update the record
|
||||
switch settings.Provider {
|
||||
case constants.NAMECHEAP:
|
||||
ip, err = updateNamecheap(
|
||||
u.client,
|
||||
settings.Host,
|
||||
settings.Domain,
|
||||
settings.Password,
|
||||
ip,
|
||||
)
|
||||
case constants.GODADDY:
|
||||
err = updateGoDaddy(
|
||||
u.client,
|
||||
settings.Host,
|
||||
settings.Domain,
|
||||
settings.Key,
|
||||
settings.Secret,
|
||||
ip,
|
||||
)
|
||||
case constants.DUCKDNS:
|
||||
ip, err = updateDuckDNS(
|
||||
u.client,
|
||||
settings.Domain,
|
||||
settings.Token,
|
||||
ip,
|
||||
)
|
||||
case constants.DREAMHOST:
|
||||
err = updateDreamhost(
|
||||
u.client,
|
||||
settings.Domain,
|
||||
settings.Key,
|
||||
settings.BuildDomainName(),
|
||||
ip,
|
||||
)
|
||||
case constants.CLOUDFLARE:
|
||||
err = updateCloudflare(
|
||||
u.client,
|
||||
settings.ZoneIdentifier,
|
||||
settings.Identifier,
|
||||
settings.Host,
|
||||
settings.Email,
|
||||
settings.Key,
|
||||
settings.UserServiceKey,
|
||||
settings.Token,
|
||||
settings.Proxied,
|
||||
settings.TTL,
|
||||
ip,
|
||||
)
|
||||
case constants.NOIP:
|
||||
ip, err = updateNoIP(
|
||||
u.client,
|
||||
settings.BuildDomainName(),
|
||||
settings.Username,
|
||||
settings.Password,
|
||||
ip,
|
||||
)
|
||||
case constants.DNSPOD:
|
||||
err = updateDNSPod(
|
||||
u.client,
|
||||
settings.Domain,
|
||||
settings.Host,
|
||||
settings.Token,
|
||||
ip,
|
||||
)
|
||||
case constants.INFOMANIAK:
|
||||
ip, err = updateInfomaniak(
|
||||
u.client,
|
||||
settings.Domain,
|
||||
settings.Host,
|
||||
settings.Username,
|
||||
settings.Password,
|
||||
ip,
|
||||
)
|
||||
case constants.DDNSSDE:
|
||||
err = updateDDNSS(
|
||||
u.client,
|
||||
settings.Domain,
|
||||
settings.Host,
|
||||
settings.Username,
|
||||
settings.Password,
|
||||
ip,
|
||||
)
|
||||
default:
|
||||
err = fmt.Errorf("provider %q is not supported", settings.Provider)
|
||||
}
|
||||
if err != nil {
|
||||
return constants.FAIL, "", nil, err
|
||||
}
|
||||
if ip != nil && ip.Equal(currentIP) {
|
||||
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
|
||||
}
|
||||
return constants.SUCCESS, fmt.Sprintf("changed to %s", ip.String()), ip, nil
|
||||
}
|
||||
|
||||
func (u *updater) incCounter() (value int) {
|
||||
u.counterMutex.Lock()
|
||||
defer u.counterMutex.Unlock()
|
||||
value = u.counter
|
||||
u.counter++
|
||||
return value
|
||||
}
|
||||
|
||||
func (u *updater) getPublicIP(ipMethod models.IPMethod, ipVersion models.IPVersion) (ip net.IP, err error) {
|
||||
var url string
|
||||
switch {
|
||||
case ipMethod == constants.PROVIDER:
|
||||
return nil, nil
|
||||
case strings.HasPrefix(string(ipMethod), "https://"):
|
||||
// Custom URL provided
|
||||
url = string(ipMethod)
|
||||
case ipMethod == constants.CYCLE:
|
||||
i := u.incCounter() % len(u.ipMethods)
|
||||
url = constants.IPMethodMapping()[u.ipMethods[i]]
|
||||
default:
|
||||
var ok bool
|
||||
url, ok = constants.IPMethodMapping()[ipMethod]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("IP method %q not supported", ipMethod)
|
||||
}
|
||||
}
|
||||
return network.GetPublicIP(u.client, url, ipVersion)
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/qdm12/ddns-updater/internal/constants"
|
||||
"github.com/qdm12/ddns-updater/internal/models"
|
||||
"github.com/qdm12/golibs/network/mock_network"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_incCounter(t *testing.T) {
|
||||
t.Parallel()
|
||||
const initValue = 100
|
||||
u := &updater{
|
||||
counter: initValue,
|
||||
}
|
||||
counter := u.incCounter()
|
||||
assert.Equal(t, initValue, counter)
|
||||
counter = u.incCounter()
|
||||
assert.Equal(t, initValue+1, counter)
|
||||
}
|
||||
|
||||
func Test_getPublicIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
IPMethod models.IPMethod
|
||||
mockURL string
|
||||
mockContent []byte
|
||||
ip net.IP
|
||||
err error
|
||||
}{
|
||||
"bad IP method": {
|
||||
IPMethod: "abc",
|
||||
err: fmt.Errorf("IP method \"abc\" not supported"),
|
||||
},
|
||||
"provider IP method": {
|
||||
IPMethod: constants.PROVIDER,
|
||||
},
|
||||
"OpenDNS IP method": {
|
||||
IPMethod: constants.OPENDNS,
|
||||
mockURL: constants.IPMethodMapping()[constants.OPENDNS],
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
"Custom URL IP method": {
|
||||
IPMethod: models.IPMethod("https://ipinfo.io/ip"),
|
||||
mockURL: "https://ipinfo.io/ip",
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
"Cycle IP method": {
|
||||
IPMethod: constants.CYCLE,
|
||||
mockURL: constants.IPMethodMapping()[constants.OPENDNS],
|
||||
mockContent: []byte("blabla 58.67.201.151.25 sds"),
|
||||
ip: net.IP{58, 67, 201, 151},
|
||||
},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockCtrl := gomock.NewController(t)
|
||||
defer mockCtrl.Finish()
|
||||
client := mock_network.NewMockClient(mockCtrl)
|
||||
if len(tc.mockURL) != 0 {
|
||||
client.EXPECT().GetContent(tc.mockURL).Return(tc.mockContent, http.StatusOK, nil).Times(1)
|
||||
}
|
||||
u := &updater{
|
||||
client: client,
|
||||
ipMethods: []models.IPMethod{constants.OPENDNS, constants.IPINFO},
|
||||
}
|
||||
ip, err := u.getPublicIP(tc.IPMethod, constants.IPv4)
|
||||
if tc.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.True(t, tc.ip.Equal(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
<th>Domain</th>
|
||||
<th>Host</th>
|
||||
<th>Provider</th>
|
||||
<th>IP method</th>
|
||||
<th>IP version</th>
|
||||
<th>Update status</th>
|
||||
<th>Set IP</th>
|
||||
<th>Previous IPs (reverse chronological order)</th>
|
||||
@@ -62,7 +62,7 @@
|
||||
<td>{{.Domain}}</td>
|
||||
<td>{{.Host}}</td>
|
||||
<td>{{.Provider}}</td>
|
||||
<td>{{.IPMethod}}</td>
|
||||
<td>{{.IPVersion}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.CurrentIP}}</td>
|
||||
<td>{{.PreviousIPs}}</td>
|
||||
|
||||
Reference in New Issue
Block a user