mirror of
https://github.com/qdm12/ddns-updater.git
synced 2026-04-05 08:54:09 -04:00
Refactor entire Go codebase (#32)
- Small UI adjustments - Only show last 2 previous IP addresses in notifications and UI - Database uses interfaces to be modular/pluggable in order to move away from sqlite - Less dependencies, it even uses a switch statement instead of httprouter - Updated golibs - Changed default logging format to `console` (zap) - Better code overall, modular updater and trigger system - Refactored readme - CI script improved
This commit is contained in:
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,3 +1 @@
|
|||||||
{
|
{}
|
||||||
"go.inferGopath": false
|
|
||||||
}
|
|
||||||
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
ARG ALPINE_VERSION=3.10
|
ARG ALPINE_VERSION=3.11
|
||||||
ARG GO_VERSION=1.13
|
ARG GO_VERSION=1.13
|
||||||
|
|
||||||
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
|
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
|
||||||
@@ -35,14 +35,17 @@ RUN apk add --update sqlite ca-certificates && \
|
|||||||
chmod 700 /updater/data && \
|
chmod 700 /updater/data && \
|
||||||
chmod 700 /updater/data/updates.db
|
chmod 700 /updater/data/updates.db
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=2 CMD ["/updater/app", "healthcheck"]
|
HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
|
||||||
USER 1000
|
USER 1000
|
||||||
ENTRYPOINT ["/updater/app"]
|
ENTRYPOINT ["/updater/app"]
|
||||||
ENV DELAY=600 \
|
ENV DELAY=10m \
|
||||||
ROOT_URL=/ \
|
ROOT_URL=/ \
|
||||||
LISTENING_PORT=8000 \
|
LISTENING_PORT=8000 \
|
||||||
LOG_ENCODING=console \
|
LOG_ENCODING=console \
|
||||||
LOG_LEVEL=info \
|
LOG_LEVEL=info \
|
||||||
NODE_ID=0
|
NODE_ID=0 \
|
||||||
|
HTTP_TIMEOUT=10s \
|
||||||
|
GOTIFY_URL= \
|
||||||
|
GOTIFY_TOKEN=
|
||||||
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
|
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
|
||||||
COPY --chown=1000 ui/* /updater/ui/
|
COPY --chown=1000 ui/* /updater/ui/
|
||||||
|
|||||||
147
README.md
147
README.md
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
*Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod and DuckDNS*
|
*Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod and DuckDNS*
|
||||||
|
|
||||||
**WARNING: Env variables naming changed slightly, see below**
|
|
||||||
|
|
||||||
[](https://hub.docker.com/r/qmcgaw/ddns-updater)
|
[](https://hub.docker.com/r/qmcgaw/ddns-updater)
|
||||||
|
|
||||||
[](https://travis-ci.org/qdm12/ddns-updater)
|
[](https://travis-ci.org/qdm12/ddns-updater)
|
||||||
@@ -24,7 +22,7 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Lightweight based on a Go binary and *Alpine 3.10* with Sqlite and Ca-Certificates packages
|
- Lightweight based on a Go binary and *Alpine 3.11* with Sqlite and Ca-Certificates packages
|
||||||
- Persistence with a sqlite database to store old IP addresses and previous update status
|
- Persistence with a sqlite database to store old IP addresses and previous update status
|
||||||
- Docker healthcheck verifying the DNS resolution of your domains
|
- Docker healthcheck verifying the DNS resolution of your domains
|
||||||
- Highly configurable
|
- Highly configurable
|
||||||
@@ -80,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See more information at the [configuration section](#configuration)
|
See more information in the [configuration section](#configuration)
|
||||||
|
|
||||||
1. Use the following command:
|
1. Use the following command:
|
||||||
|
|
||||||
@@ -96,100 +94,102 @@
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Record configuration
|
Start by having the following content in *config.json*:
|
||||||
|
|
||||||
The record update updates configuration must be done through the *config.json* mentioned [above](#setup).
|
```json
|
||||||
|
{
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"provider": "",
|
||||||
|
"domain": "",
|
||||||
|
"ip_method": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "",
|
||||||
|
"domain": "",
|
||||||
|
"ip_method": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Required parameters for all
|
The following parameters are to be added in *config.json*
|
||||||
|
|
||||||
- `"provider"` is the DNS provider and can be:
|
For all record update configuration, you need the following:
|
||||||
- `godaddy`
|
|
||||||
- `namecheap`
|
|
||||||
- `duckdns`
|
|
||||||
- `dreamhost`
|
|
||||||
- `cloudflare`
|
|
||||||
- `noip`
|
|
||||||
- `dnspod`
|
|
||||||
- `"domain"` is your domain name
|
|
||||||
- `"ip_method"` is the method to obtain your public IP address and can be
|
|
||||||
- `provider` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap and NoIP**)
|
|
||||||
- ~`duckduckgo` using [https://duckduckgo.com/?q=ip](https://duckduckgo.com/?q=ip)~ no longer returns your IP
|
|
||||||
- `google` using [https://google.com/search?q=ip](https://google.com/search?q=ip)
|
|
||||||
- `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
|
|
||||||
|
|
||||||
Please then refer to your specific DNS host provider in the section below for eventual additional required parameters.
|
- `"provider"` is the DNS provider and can be `"godaddy"`, `"namecheap"`, `"duckdns"`, `"dreamhost"`, `"cloudflare"`, `"noip"`, or `"dnspod"`
|
||||||
|
- `"domain"`
|
||||||
|
- `"ip_method"` is the method to obtain your public IP address and can be:
|
||||||
|
- `"provider"` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap and NoIP**)
|
||||||
|
- `"google"` using [https://google.com/search?q=ip](https://google.com/search?q=ip)
|
||||||
|
- `"opendns"` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
|
||||||
|
|
||||||
#### Optional parameters for all
|
You can optionnally add the parameters:
|
||||||
|
|
||||||
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable which itself defaults to 5 minutes.
|
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable value.
|
||||||
- `"no_dns_lookup"` is a boolean to prevent the regular Docker healthcheck from running a DNS lookup on your domain. This is useful in some corner cases.
|
- `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the periodic Docker healthcheck from running a DNS lookup on your domain.
|
||||||
|
|
||||||
#### Namecheap
|
For each DNS provider exist some specific parameters you need to add, as described below:
|
||||||
|
|
||||||
- Required:
|
Namecheap:
|
||||||
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
|
||||||
- `"password"`
|
|
||||||
|
|
||||||
#### Cloudflare
|
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
||||||
|
- `"password"`
|
||||||
|
|
||||||
- Required:
|
Cloudflare:
|
||||||
- `"zone_identifier"`
|
|
||||||
- `"identifier"`
|
|
||||||
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
|
||||||
- `"ttl"` integer value for record TTL (specify 1 for automatic)
|
|
||||||
- Either:
|
|
||||||
- Email `"email"` and Key `"key"`
|
|
||||||
- User service key `"user_service_key"`
|
|
||||||
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone.
|
|
||||||
- Optional:
|
|
||||||
- `"proxied"` is a boolean to use the proxy services of Cloudflare
|
|
||||||
|
|
||||||
#### GoDaddy
|
- `"zone_identifier"`
|
||||||
|
- `"identifier"`
|
||||||
|
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
||||||
|
- `"ttl"` integer value for record TTL in seconds (specify 1 for automatic)
|
||||||
|
- One of the following:
|
||||||
|
- Email `"email"` and key `"key"`
|
||||||
|
- User service key `"user_service_key"`
|
||||||
|
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone.
|
||||||
|
- *Optionally*, `"proxied"` can be `true` or `false` to use the proxy services of Cloudflare
|
||||||
|
|
||||||
- Required:
|
GoDaddy:
|
||||||
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
|
||||||
- `"key"`
|
|
||||||
- `"secret"`
|
|
||||||
|
|
||||||
#### DuckDNS
|
- `"host"` is your host and can be a subdomain, `@` or `*` generally
|
||||||
|
- `"key"`
|
||||||
|
- `"secret"`
|
||||||
|
|
||||||
- Required:
|
DuckDNS:
|
||||||
- `"token"`
|
|
||||||
|
|
||||||
#### Dreamhost
|
- `"token"`
|
||||||
|
|
||||||
- Required:
|
Dreamhost:
|
||||||
- `"key"`
|
|
||||||
|
|
||||||
#### NoIP
|
- `"key"`
|
||||||
|
|
||||||
- Required:
|
NoIP:
|
||||||
- `"host"` is your host and can be a subdomain or `@`
|
|
||||||
- `"username"` which is your username
|
|
||||||
- `"password"`
|
|
||||||
|
|
||||||
#### DNSPOD
|
- `"host"` is your host and can be a subdomain or `@`
|
||||||
|
- `"username"`
|
||||||
|
- `"password"`
|
||||||
|
|
||||||
- Required:
|
DNSPOD:
|
||||||
- `"host"` is your host and can be a subdomain or `@`
|
|
||||||
- `"token"`
|
- `"host"` is your host and can be a subdomain or `@`
|
||||||
|
- `"token"`
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Environment variable | Default | Description |
|
| Environment variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `DELAY` | `300` | Delay between updates in seconds |
|
| `DELAY` | `10m` | Default delay between updates, following [this format](https://golang.org/pkg/time/#ParseDuration) |
|
||||||
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
|
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
|
||||||
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
|
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
|
||||||
| `LOG_ENCODING` | `json` | Format of logging, `json` or `human` |
|
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
|
||||||
| `LOG_LEVEL` | `info` | Level of logging, `info`, ~`success`~, `warning` or `error` |
|
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
|
||||||
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
|
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
|
||||||
| `GOTIFY_URL` | | HTTP(s) URL to your Gotify server |
|
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
|
||||||
| `GOTIFY_TOKEN` | | Token to access your Gotify server |
|
| `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
|
||||||
|
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
|
||||||
|
|
||||||
### Host firewall
|
### Host firewall
|
||||||
|
|
||||||
This container needs the following ports:
|
If you have a host firewall in place, this container needs the following ports:
|
||||||
|
|
||||||
- TCP 443 outbound for outbound HTTPS
|
- TCP 443 outbound for outbound HTTPS
|
||||||
- TCP 80 outbound if you use a local unsecured HTTP connection to your Gotify server
|
- TCP 80 outbound if you use a local unsecured HTTP connection to your Gotify server
|
||||||
@@ -278,13 +278,9 @@ You can now fill in the necessary parameters in *config.json*
|
|||||||
|
|
||||||
Special thanks to @Starttoaster for helping out with the [documentation](https://gist.github.com/Starttoaster/07d568c2a99ad7631dd776688c988326) and testing.
|
Special thanks to @Starttoaster for helping out with the [documentation](https://gist.github.com/Starttoaster/07d568c2a99ad7631dd776688c988326) and testing.
|
||||||
|
|
||||||
### NoIP
|
|
||||||
|
|
||||||
*Awaiting a contribution*
|
|
||||||
|
|
||||||
## Gotify
|
## Gotify
|
||||||
|
|
||||||
[](https://gotify.net)
|
[](https://gotify.net)
|
||||||
|
|
||||||
[**Gotify**](https://gotify.net) is a simple server for sending and receiving messages, and it is **free**, **private** and **open source**
|
[**Gotify**](https://gotify.net) is a simple server for sending and receiving messages, and it is **free**, **private** and **open source**
|
||||||
- It has an [Android app](https://play.google.com/store/apps/details?id=com.github.gotify) to receive notifications
|
- It has an [Android app](https://play.google.com/store/apps/details?id=com.github.gotify) to receive notifications
|
||||||
@@ -307,8 +303,6 @@ To set it up with DDNS updater:
|
|||||||
[](https://dcc.godaddy.com/manage/)
|
[](https://dcc.godaddy.com/manage/)
|
||||||
|
|
||||||
You might want to try to change the IP address to another one to see if the update actually occurs.
|
You might want to try to change the IP address to another one to see if the update actually occurs.
|
||||||
- Namecheap: *awaiting contribution*
|
|
||||||
- DuckDNS: *awaiting contribution*
|
|
||||||
|
|
||||||
## Used in external projects
|
## Used in external projects
|
||||||
|
|
||||||
@@ -324,13 +318,16 @@ To set it up with DDNS updater:
|
|||||||
1. In Visual Studio Code, press on `F1` and select `Remote-Containers: Open Folder in Container...`
|
1. In Visual Studio Code, press on `F1` and select `Remote-Containers: Open Folder in Container...`
|
||||||
1. Your dev environment is ready to go!... and it's running in a container :+1:
|
1. Your dev environment is ready to go!... and it's running in a container :+1:
|
||||||
|
|
||||||
|
You can probably start looking at the cmd/updater/main.go file which is the entrypoint of the program.
|
||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
|
- [ ] Changed from sqlite to bolt or similar
|
||||||
|
- [ ] Support Infomaniak.com
|
||||||
- [ ] icon.ico for webpage
|
- [ ] icon.ico for webpage
|
||||||
- [ ] Record events log
|
- [ ] Record events log
|
||||||
- [ ] Use internal package instead of pkg
|
- [ ] Use internal package instead of pkg
|
||||||
- [ ] Hot reload of config.json
|
- [ ] Hot reload of config.json
|
||||||
- [ ] Changed from sqlite to bolt or similar
|
|
||||||
- [ ] Unit tests
|
- [ ] Unit tests
|
||||||
- [ ] Other types or records
|
- [ ] Other types or records
|
||||||
- [ ] ReactJS frontend
|
- [ ] ReactJS frontend
|
||||||
|
|||||||
12
ci.sh
12
ci.sh
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ "$TRAVIS_PULL_REQUEST" = "true" ] || [ "$TRAVIS_BRANCH" != "master" ]; then
|
if [ "$TRAVIS_PULL_REQUEST" = "true" ]; then
|
||||||
|
echo "Building pull request without pushing to Docker Hub"
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--progress plain \
|
--progress plain \
|
||||||
--platform=linux/amd64,linux/386,linux/arm64,linux/arm/v7 \
|
--platform=linux/amd64,linux/386,linux/arm64,linux/arm/v7 \
|
||||||
@@ -8,7 +9,14 @@ if [ "$TRAVIS_PULL_REQUEST" = "true" ] || [ "$TRAVIS_BRANCH" != "master" ]; then
|
|||||||
exit $?
|
exit $?
|
||||||
fi
|
fi
|
||||||
echo $DOCKER_PASSWORD | docker login -u qmcgaw --password-stdin &> /dev/null
|
echo $DOCKER_PASSWORD | docker login -u qmcgaw --password-stdin &> /dev/null
|
||||||
TAG="${TRAVIS_TAG:-latest}"
|
TAG="$TRAVIS_TAG"
|
||||||
|
if [ -z $TAG ]; then
|
||||||
|
if [ "$TRAVIS_BRANCH" = "master" ]; then
|
||||||
|
TAG=latest
|
||||||
|
else
|
||||||
|
TAG="$TRAVIS_BRANCH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
echo "Building Docker images for \"$DOCKER_REPO:$TAG\""
|
echo "Building Docker images for \"$DOCKER_REPO:$TAG\""
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--progress plain \
|
--progress plain \
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,27 +11,41 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/qdm12/ddns-updater/internal/database"
|
"github.com/kyokomi/emoji"
|
||||||
"github.com/qdm12/ddns-updater/internal/env"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/params"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/router"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/update"
|
|
||||||
"github.com/qdm12/golibs/admin"
|
"github.com/qdm12/golibs/admin"
|
||||||
"github.com/qdm12/golibs/healthcheck"
|
libhealthcheck "github.com/qdm12/golibs/healthcheck"
|
||||||
"github.com/qdm12/golibs/logging"
|
"github.com/qdm12/golibs/logging"
|
||||||
"github.com/qdm12/golibs/network"
|
"github.com/qdm12/golibs/network"
|
||||||
libparams "github.com/qdm12/golibs/params"
|
libparams "github.com/qdm12/golibs/params"
|
||||||
"github.com/qdm12/golibs/server"
|
"github.com/qdm12/golibs/server"
|
||||||
|
|
||||||
"github.com/kyokomi/emoji"
|
|
||||||
"github.com/qdm12/golibs/signals"
|
"github.com/qdm12/golibs/signals"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/data"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/env"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/handlers"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/healthcheck"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/params"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/persistence"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/trigger"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if healthcheck.Mode(os.Args) {
|
logger, err := logging.NewLogger(logging.ConsoleEncoding, logging.InfoLevel, -1)
|
||||||
if err := healthcheck.Query(); err != nil {
|
if err != nil {
|
||||||
logging.Err(err)
|
panic(err)
|
||||||
|
}
|
||||||
|
paramsReader := params.NewParamsReader(logger)
|
||||||
|
encoding, level, nodeID, err := paramsReader.GetLoggerConfig()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err)
|
||||||
|
} else {
|
||||||
|
logger, err = logging.NewLogger(encoding, level, nodeID)
|
||||||
|
}
|
||||||
|
if libhealthcheck.Mode(os.Args) {
|
||||||
|
if err := libhealthcheck.Query(); err != nil {
|
||||||
|
logger.Error(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -39,85 +56,84 @@ func main() {
|
|||||||
fmt.Println("######## Give some " + emoji.Sprint(":heart:") + "at #########")
|
fmt.Println("######## Give some " + emoji.Sprint(":heart:") + "at #########")
|
||||||
fmt.Println("# github.com/qdm12/ddns-updater #")
|
fmt.Println("# github.com/qdm12/ddns-updater #")
|
||||||
fmt.Print("#################################\n\n")
|
fmt.Print("#################################\n\n")
|
||||||
envParams := libparams.NewEnvParams()
|
e := env.NewEnv(logger)
|
||||||
encoding, level, nodeID, err := envParams.GetLoggerConfig()
|
gotify, err := setupGotify(paramsReader)
|
||||||
if err != nil {
|
e.FatalOnError(err)
|
||||||
logging.Error(err.Error())
|
e.SetGotify(gotify)
|
||||||
} else {
|
listeningPort, warning, err := paramsReader.GetListeningPort()
|
||||||
logging.InitLogger(encoding, level, nodeID)
|
e.FatalOnError(err)
|
||||||
|
if len(warning) > 0 {
|
||||||
|
logger.Warn(warning)
|
||||||
}
|
}
|
||||||
var e env.Env
|
rootURL, err := paramsReader.GetRootURL()
|
||||||
HTTPTimeout, err := envParams.GetHTTPTimeout(time.Millisecond, libparams.Default("3000"))
|
|
||||||
e.CheckError(err)
|
|
||||||
e.Client = network.NewClient(HTTPTimeout)
|
|
||||||
e.Gotify, err = setupGotify(envParams)
|
|
||||||
listeningPort, err := envParams.GetListeningPort()
|
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
rootURL, err := envParams.GetRootURL()
|
defaultPeriod, err := paramsReader.GetDelay(libparams.Default("10m"))
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
delay, err := envParams.GetDuration("DELAY", time.Second, libparams.Default("600"))
|
dir, err := paramsReader.GetExeDir()
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
dir, err := envParams.GetExeDir()
|
dataDir, err := paramsReader.GetDataDir(dir)
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
dataDir, err := params.GetDataDir(envParams, dir)
|
persistentDB, err := persistence.NewSQLite(dataDir)
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
e.SQL, err = database.NewDB(dataDir)
|
|
||||||
e.FatalOnError(err)
|
|
||||||
defer e.SQL.Close()
|
|
||||||
go signals.WaitForExit(e.ShutdownFromSignal)
|
go signals.WaitForExit(e.ShutdownFromSignal)
|
||||||
settings, warnings, err := params.GetSettings(dataDir + "/config.json")
|
settings, warnings, err := paramsReader.GetSettings(dataDir + "/config.json")
|
||||||
for _, w := range warnings {
|
for _, w := range warnings {
|
||||||
e.Warn(w)
|
e.Warn(w)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.Fatal(err)
|
e.Fatal(err)
|
||||||
}
|
}
|
||||||
logging.Infof("Found %d settings to update records", len(settings))
|
logger.Info("Found %d settings to update records", len(settings))
|
||||||
for _, err := range network.NewConnectivity(5 * time.Second).Checks("google.com") {
|
for _, err := range network.NewConnectivity(5 * time.Second).Checks("google.com") {
|
||||||
e.Warn(err)
|
e.Warn(err)
|
||||||
}
|
}
|
||||||
var recordsConfigs []models.RecordConfigType
|
var records []models.Record
|
||||||
for _, s := range settings {
|
idToPeriod := make(map[int]time.Duration)
|
||||||
logging.Infof("Reading history from database: domain %s host %s", s.Domain, s.Host)
|
for id, setting := range settings {
|
||||||
ips, tSuccess, err := e.SQL.GetIps(s.Domain, s.Host)
|
logger.Info("Reading history from database: domain %s host %s", setting.Domain, setting.Host)
|
||||||
|
ips, successTime, err := persistentDB.GetIPs(setting.Domain, setting.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.FatalOnError(err)
|
e.FatalOnError(err)
|
||||||
}
|
}
|
||||||
recordsConfigs = append(recordsConfigs, models.NewRecordConfig(s, ips, tSuccess))
|
records = append(records, models.NewRecord(setting, ips, successTime))
|
||||||
|
idToPeriod[id] = defaultPeriod
|
||||||
|
if setting.Delay > 0 {
|
||||||
|
idToPeriod[id] = setting.Delay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
chForce := make(chan struct{})
|
HTTPTimeout, err := paramsReader.GetHTTPTimeout()
|
||||||
chQuit := make(chan struct{})
|
e.FatalOnError(err)
|
||||||
defer close(chForce)
|
client := network.NewClient(HTTPTimeout)
|
||||||
go update.TriggerServer(delay, chForce, chQuit, recordsConfigs, e.Client, e.SQL, e.Gotify)
|
db := data.NewDatabase(records, persistentDB)
|
||||||
chForce <- struct{}{}
|
e.SetDb(db)
|
||||||
productionRouter := router.CreateRouter(rootURL, dir, chForce, recordsConfigs, e.Gotify)
|
updater := update.NewUpdater(db, logger, client, e.Notify)
|
||||||
healthcheckRouter := healthcheck.CreateRouter(func() error {
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return router.IsHealthy(recordsConfigs)
|
defer cancel()
|
||||||
|
forceUpdate := trigger.StartUpdates(ctx, updater, idToPeriod, e.CheckError)
|
||||||
|
forceUpdate()
|
||||||
|
productionHandlerFunc := handlers.NewHandler(rootURL, dir, db, logger, forceUpdate, e.CheckError).GetHandlerFunc()
|
||||||
|
healthcheckHandlerFunc := libhealthcheck.GetHandler(func() error {
|
||||||
|
return healthcheck.IsHealthy(db, net.LookupIP, logger)
|
||||||
})
|
})
|
||||||
logging.Infof("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL)
|
logger.Info("Web UI listening at address 0.0.0.0:%s with root URL %s", listeningPort, rootURL)
|
||||||
if e.Gotify != nil {
|
e.Notify(1, fmt.Sprintf("Just launched\nIt has %d records to watch", len(records)))
|
||||||
e.Gotify.Notify("DDNS Updater", 1, "Just launched\nIt has %d records to watch", len(recordsConfigs))
|
|
||||||
}
|
|
||||||
serverErrs := server.RunServers(
|
serverErrs := server.RunServers(
|
||||||
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionRouter},
|
server.Settings{Name: "production", Addr: "0.0.0.0:" + listeningPort, Handler: productionHandlerFunc},
|
||||||
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckRouter},
|
server.Settings{Name: "healthcheck", Addr: "127.0.0.1:9999", Handler: healthcheckHandlerFunc},
|
||||||
)
|
)
|
||||||
for _, err := range serverErrs {
|
|
||||||
e.CheckError(err)
|
|
||||||
}
|
|
||||||
if len(serverErrs) > 0 {
|
if len(serverErrs) > 0 {
|
||||||
e.Fatal(serverErrs)
|
e.Fatal(serverErrs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupGotify(envParams libparams.EnvParams) (admin.Gotify, error) {
|
func setupGotify(paramsReader params.ParamsReader) (admin.Gotify, error) {
|
||||||
URL, err := envParams.GetGotifyURL()
|
URL, err := paramsReader.GetGotifyURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if URL == nil {
|
} else if URL == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
token, err := envParams.GetGotifyToken()
|
token, err := paramsReader.GetGotifyToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/updater/data
|
- ./data:/updater/data
|
||||||
environment:
|
environment:
|
||||||
- DELAY=300
|
- DELAY=300s
|
||||||
- ROOTURL=
|
- ROOT_URL=/
|
||||||
- LISTENINGPORT=8000
|
- LISTENINGPORT=8000
|
||||||
- LOGGING=human
|
- LOG_ENCODING=console
|
||||||
- NODEID=0
|
- LOG_LEVEL=info
|
||||||
- LOGLEVEL=
|
- NODE_ID=0
|
||||||
|
- HTTP_TIMEOUT=10s
|
||||||
|
- GOTIFY_URL=
|
||||||
|
- GOTIFY_TOKEN=
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -4,8 +4,7 @@ go 1.13
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.1.1
|
github.com/google/uuid v1.1.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
|
||||||
github.com/kyokomi/emoji v2.1.0+incompatible
|
github.com/kyokomi/emoji v2.1.0+incompatible
|
||||||
github.com/mattn/go-sqlite3 v1.10.0
|
github.com/mattn/go-sqlite3 v1.10.0
|
||||||
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23
|
github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd
|
||||||
)
|
)
|
||||||
|
|||||||
24
go.sum
24
go.sum
@@ -37,16 +37,12 @@ github.com/go-openapi/validate v0.17.0 h1:pqoViQz3YLOGIhAmD0N4Lt6pa/3Gnj3ymKqQwq
|
|||||||
github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
|
github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
|
||||||
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
|
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
|
||||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||||
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gotify/go-api-client/v2 v2.0.4 h1:0w8skCr8aLBDKaQDg31LKKHUGF7rt7zdRpR+6cqIAlE=
|
github.com/gotify/go-api-client/v2 v2.0.4 h1:0w8skCr8aLBDKaQDg31LKKHUGF7rt7zdRpR+6cqIAlE=
|
||||||
github.com/gotify/go-api-client/v2 v2.0.4/go.mod h1:VKiah/UK20bXsr0JObE1eBVLW44zbBouzjuri9iwjFU=
|
github.com/gotify/go-api-client/v2 v2.0.4/go.mod h1:VKiah/UK20bXsr0JObE1eBVLW44zbBouzjuri9iwjFU=
|
||||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
|
||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -61,6 +57,8 @@ github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK86
|
|||||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc=
|
||||||
|
github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
|
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
|
||||||
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
||||||
github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY=
|
github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY=
|
||||||
@@ -68,8 +66,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
|||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23 h1:PdABK6xsIrNLtUgbB1pRL3PvmZagwjx9aWkvKDx5IA0=
|
github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd h1:6Ip2U6WYjPnrdB/gdz/YXki/Z3WqLDc06gJe852x2IQ=
|
||||||
github.com/qdm12/golibs v0.0.0-20200105230400-fd52479cef23/go.mod h1:oNw3Ie4T0wyeBk9fK8vOe5tKdWG3bEcII6RlQjw7JIs=
|
github.com/qdm12/golibs v0.0.0-20200215191021-97f4a70f16bd/go.mod h1:YULaFjj6VGmhjak6f35sUWwEleHUmngN5IQ3kdvd6XE=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -78,17 +76,17 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
|||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
|
||||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM=
|
go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc=
|
||||||
go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||||
go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
|
|
||||||
go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
|
||||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
|
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
|
||||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||||
go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=
|
go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU=
|
||||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
@@ -117,7 +115,5 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
|
||||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
|
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
|||||||
17
internal/constants/ipmethod.go
Normal file
17
internal/constants/ipmethod.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
import "github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
|
||||||
|
const (
|
||||||
|
IPMETHODPROVIDER models.IPMethod = "provider"
|
||||||
|
IPMETHODGOOGLE models.IPMethod = "google"
|
||||||
|
IPMETHODOPENDNS models.IPMethod = "opendns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IPMethodChoices() (choices []models.IPMethod) {
|
||||||
|
return []models.IPMethod{
|
||||||
|
IPMETHODPROVIDER,
|
||||||
|
IPMETHODGOOGLE,
|
||||||
|
IPMETHODOPENDNS,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/constants/providers.go
Normal file
26
internal/constants/providers.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
import "github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
|
||||||
|
// All possible provider values
|
||||||
|
const (
|
||||||
|
PROVIDERGODADDY models.Provider = "godaddy"
|
||||||
|
PROVIDERNAMECHEAP models.Provider = "namecheap"
|
||||||
|
PROVIDERDUCKDNS models.Provider = "duckdns"
|
||||||
|
PROVIDERDREAMHOST models.Provider = "dreamhost"
|
||||||
|
PROVIDERCLOUDFLARE models.Provider = "cloudflare"
|
||||||
|
PROVIDERNOIP models.Provider = "noip"
|
||||||
|
PROVIDERDNSPOD models.Provider = "dnspod"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProviderChoices() (choices []models.Provider) {
|
||||||
|
return []models.Provider{
|
||||||
|
PROVIDERGODADDY,
|
||||||
|
PROVIDERNAMECHEAP,
|
||||||
|
PROVIDERDUCKDNS,
|
||||||
|
PROVIDERDREAMHOST,
|
||||||
|
PROVIDERCLOUDFLARE,
|
||||||
|
PROVIDERNOIP,
|
||||||
|
PROVIDERDNSPOD,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/constants/regex.go
Normal file
46
internal/constants/regex.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
const (
|
||||||
|
RegexGoDaddyKey string = `[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}`
|
||||||
|
RegexGodaddySecret string = `[A-Za-z0-9]{22}`
|
||||||
|
RegexDuckDNSToken string = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}`
|
||||||
|
RegexNamecheapPassword string = `[a-f0-9]{32}`
|
||||||
|
RegexDreamhostKey string = `[a-zA-Z0-9]{16}`
|
||||||
|
RegexCloudflareKey string = `[a-zA-Z0-9]+`
|
||||||
|
RegexCloudflareUserServiceKey string = `v1\.0.+`
|
||||||
|
RegexCloudflareToken string = `[a-zA-Z0-9_]{40}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func MatchGodaddyKey(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexGoDaddyKey + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchGodaddySecret(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexGodaddySecret + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchDuckDNSToken(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexDuckDNSToken + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchNamecheapPassword(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexNamecheapPassword + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchDreamhostKey(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexDreamhostKey + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchCloudflareKey(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexCloudflareKey + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchCloudflareUserServiceKey(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexCloudflareUserServiceKey + "$").MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchCloudflareToken(s string) bool {
|
||||||
|
return regexp.MustCompile("^" + RegexCloudflareToken + "$").MatchString(s)
|
||||||
|
}
|
||||||
17
internal/constants/status.go
Normal file
17
internal/constants/status.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
import "github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
|
||||||
|
const (
|
||||||
|
FAIL models.Status = "failure"
|
||||||
|
SUCCESS models.Status = "success"
|
||||||
|
UPTODATE models.Status = "up to date"
|
||||||
|
UPDATING models.Status = "updating"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HTML_FAIL string = `<font color="red"><b>Failure</b></font>`
|
||||||
|
HTML_SUCCESS string = `<font color="green"><b>Success</b></font>`
|
||||||
|
HTML_UPTODATE string = `<font color="#00CC66"><b>Up to date</b></font>`
|
||||||
|
HTML_UPDATING string = `<font color="orange"><b>Updating</b></font>`
|
||||||
|
)
|
||||||
10
internal/constants/url.go
Normal file
10
internal/constants/url.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
const (
|
||||||
|
NamecheapURL = "https://dynamicdns.park-your-domain.com/update"
|
||||||
|
GodaddyURL = "https://api.godaddy.com/v1/domains"
|
||||||
|
DuckdnsURL = "https://www.duckdns.org/update"
|
||||||
|
DreamhostURL = "https://api.dreamhost.com"
|
||||||
|
CloudflareURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
NoIPURL = "https://dynupdate.no-ip.com/nic/update"
|
||||||
|
)
|
||||||
40
internal/data/data.go
Normal file
40
internal/data/data.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/persistence"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Database interface {
|
||||||
|
Close() error
|
||||||
|
Insert(record models.Record) (id int)
|
||||||
|
Select(id int) (record models.Record, err error)
|
||||||
|
SelectAll() (records []models.Record)
|
||||||
|
Update(id int, record models.Record) error
|
||||||
|
// From persistence database
|
||||||
|
GetIPs(domain, host string) (IPs []net.IP, timeSuccess time.Time, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type database struct {
|
||||||
|
data []models.Record
|
||||||
|
sync.RWMutex
|
||||||
|
persistentDB persistence.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase creates a new in memory database
|
||||||
|
func NewDatabase(data []models.Record, persistentDB persistence.Database) Database {
|
||||||
|
return &database{
|
||||||
|
data: data,
|
||||||
|
persistentDB: persistentDB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *database) Close() error {
|
||||||
|
db.Lock() // ensure write operation finishes
|
||||||
|
defer db.Unlock()
|
||||||
|
return db.persistentDB.Close()
|
||||||
|
}
|
||||||
32
internal/data/memory.go
Normal file
32
internal/data/memory.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *database) Insert(record models.Record) (id int) {
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
db.data = append(db.data, record)
|
||||||
|
return len(db.data) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *database) Select(id int) (record models.Record, err error) {
|
||||||
|
db.RLock()
|
||||||
|
defer db.RUnlock()
|
||||||
|
if id < 0 {
|
||||||
|
return record, fmt.Errorf("id %d cannot be lower than 0", id)
|
||||||
|
}
|
||||||
|
if id > len(db.data)-1 {
|
||||||
|
return record, fmt.Errorf("no record config found for id %d", id)
|
||||||
|
}
|
||||||
|
return db.data[id], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *database) SelectAll() (records []models.Record) {
|
||||||
|
db.RLock()
|
||||||
|
defer db.RUnlock()
|
||||||
|
return db.data
|
||||||
|
}
|
||||||
38
internal/data/persistence.go
Normal file
38
internal/data/persistence.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *database) GetIPs(domain, host string) (IPs []net.IP, timeSuccess time.Time, err error) {
|
||||||
|
return db.persistentDB.GetIPs(domain, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *database) Update(id int, record models.Record) error {
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
if id < 0 {
|
||||||
|
return fmt.Errorf("id %d cannot be lower than 0", id)
|
||||||
|
}
|
||||||
|
if id > len(db.data)-1 {
|
||||||
|
return fmt.Errorf("no record config found for id %d", id)
|
||||||
|
}
|
||||||
|
currentCount := len(db.data[id].History.IPs)
|
||||||
|
newCount := len(record.History.IPs)
|
||||||
|
db.data[id] = record
|
||||||
|
// new IP address added
|
||||||
|
if newCount > currentCount {
|
||||||
|
if err := db.persistentDB.StoreNewIP(
|
||||||
|
record.Settings.Domain,
|
||||||
|
record.Settings.Host,
|
||||||
|
record.History.GetCurrentIP(),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A sqlite database is used to store previous IPs, when re launching the program.
|
|
||||||
|
|
||||||
// SQL represents the database store actions.
|
|
||||||
// It is implemented with the database struct and methods.
|
|
||||||
// WARNING: Use in one single go routine, it is not thread safe !
|
|
||||||
type SQL interface {
|
|
||||||
Lock()
|
|
||||||
Unlock()
|
|
||||||
UpdateIPTime(domain, host, ip string) (err error)
|
|
||||||
StoreNewIP(domain, host, ip string) (err error)
|
|
||||||
GetIps(domain, host string) (ips []string, tNew time.Time, err error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type database struct {
|
|
||||||
sqlite *sql.DB
|
|
||||||
sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *database) Close() error {
|
|
||||||
return db.sqlite.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDB opens or creates the database if necessary.
|
|
||||||
func NewDB(dataDir string) (SQL, error) {
|
|
||||||
sqlite, err := sql.Open("sqlite3", dataDir+"/updates.db")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_, err = sqlite.Exec(
|
|
||||||
`CREATE TABLE IF NOT EXISTS updates_ips (
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
host TEXT NOT NULL,
|
|
||||||
ip TEXT NOT NULL,
|
|
||||||
t_new DATETIME NOT NULL,
|
|
||||||
t_last DATETIME NOT NULL,
|
|
||||||
current INTEGER DEFAULT 1 NOT NULL,
|
|
||||||
PRIMARY KEY(domain, host, ip, t_new)
|
|
||||||
);`)
|
|
||||||
return &database{sqlite: sqlite}, err
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
/* All these methods must be called by a single go routine as they are not
|
|
||||||
thread safe because of SQLite */
|
|
||||||
|
|
||||||
// UpdateIPTime updates the latest same IP update time for a certain
|
|
||||||
// domain, host and IP tuple.
|
|
||||||
func (db *database) UpdateIPTime(domain, host, ip string) (err error) {
|
|
||||||
db.Lock()
|
|
||||||
defer db.Unlock()
|
|
||||||
_, err = db.sqlite.Exec(
|
|
||||||
`UPDATE updates_ips
|
|
||||||
SET t_last = ?
|
|
||||||
WHERE domain = ? AND host = ? AND ip = ? AND current = 1`,
|
|
||||||
time.Now(),
|
|
||||||
domain,
|
|
||||||
host,
|
|
||||||
ip,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreNewIP stores a new IP address for a certain
|
|
||||||
// domain and host.
|
|
||||||
func (db *database) StoreNewIP(domain, host, ip string) (err error) {
|
|
||||||
// Disable the current IP
|
|
||||||
db.Lock()
|
|
||||||
defer db.Unlock()
|
|
||||||
_, err = db.sqlite.Exec(
|
|
||||||
`UPDATE updates_ips
|
|
||||||
SET current = 0
|
|
||||||
WHERE domain = ? AND host = ? AND current = 1`,
|
|
||||||
domain,
|
|
||||||
host,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Inserts new IP
|
|
||||||
_, err = db.sqlite.Exec(
|
|
||||||
`INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current)
|
|
||||||
VALUES(?, ?, ?, ?, ?, ?);`,
|
|
||||||
domain,
|
|
||||||
host,
|
|
||||||
ip,
|
|
||||||
time.Now(),
|
|
||||||
time.Now(),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIps gets all the IP addresses history for a certain
|
|
||||||
// domain and host.
|
|
||||||
func (db *database) GetIps(domain, host string) (ips []string, tNew time.Time, err error) {
|
|
||||||
db.Lock()
|
|
||||||
defer db.Unlock()
|
|
||||||
rows, err := db.sqlite.Query(
|
|
||||||
`SELECT ip, t_new
|
|
||||||
FROM updates_ips
|
|
||||||
WHERE domain = ? AND host = ?
|
|
||||||
ORDER BY t_new DESC`,
|
|
||||||
domain,
|
|
||||||
host,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, tNew, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var ip string
|
|
||||||
var t time.Time
|
|
||||||
var tNewSet bool
|
|
||||||
for rows.Next() {
|
|
||||||
err = rows.Scan(&ip, &t)
|
|
||||||
if err != nil {
|
|
||||||
return ips, tNew, err
|
|
||||||
}
|
|
||||||
if !tNewSet {
|
|
||||||
tNew = t
|
|
||||||
tNewSet = true
|
|
||||||
}
|
|
||||||
ips = append(ips, ip)
|
|
||||||
}
|
|
||||||
if !tNewSet {
|
|
||||||
tNew = time.Now()
|
|
||||||
}
|
|
||||||
return ips, tNew, rows.Err()
|
|
||||||
}
|
|
||||||
221
internal/env/env.go
vendored
221
internal/env/env.go
vendored
@@ -1,89 +1,132 @@
|
|||||||
package env
|
package env
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"os"
|
||||||
"os"
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/data"
|
||||||
"github.com/qdm12/ddns-updater/internal/database"
|
|
||||||
|
"github.com/qdm12/golibs/admin"
|
||||||
"github.com/qdm12/golibs/admin"
|
"github.com/qdm12/golibs/logging"
|
||||||
"github.com/qdm12/golibs/logging"
|
"github.com/qdm12/golibs/network"
|
||||||
"github.com/qdm12/golibs/network"
|
)
|
||||||
)
|
|
||||||
|
type Env interface {
|
||||||
// Env contains objects necessary to the main function.
|
SetClient(client network.Client)
|
||||||
// These are created at start and are needed to the top-level
|
SetGotify(gotify admin.Gotify)
|
||||||
// working management of the program.
|
SetDb(db data.Database)
|
||||||
type Env struct {
|
Notify(priority int, messageArgs ...interface{})
|
||||||
stopCh chan struct{}
|
Info(messageArgs ...interface{})
|
||||||
Client network.Client
|
Warn(messageArgs ...interface{})
|
||||||
Gotify admin.Gotify
|
CheckError(err error)
|
||||||
SQL database.SQL
|
FatalOnError(err error)
|
||||||
}
|
ShutdownFromSignal(signal string) (exitCode int)
|
||||||
|
Fatal(messageArgs ...interface{})
|
||||||
// Warn logs a message and sends a notification to the Gotify server.
|
Shutdown() (exitCode int)
|
||||||
func (e *Env) Warn(message interface{}) {
|
}
|
||||||
s := fmt.Sprintf("%s", message)
|
|
||||||
logging.Warn(s)
|
func NewEnv(logger logging.Logger) Env {
|
||||||
if e.Gotify != nil {
|
return &env{logger: logger}
|
||||||
e.Gotify.Notify("Warning", 2, s)
|
}
|
||||||
}
|
|
||||||
}
|
// env contains objects necessary to the main function.
|
||||||
|
// These are created at start and are needed to the top-level
|
||||||
// CheckError logs an error and sends a notification to the Gotify server
|
// working management of the program.
|
||||||
// if the error is not nil.
|
type env struct {
|
||||||
func (e *Env) CheckError(err error) {
|
logger logging.Logger
|
||||||
if err == nil {
|
client network.Client
|
||||||
return
|
gotify admin.Gotify
|
||||||
}
|
db data.Database
|
||||||
s := err.Error()
|
}
|
||||||
logging.Errorf(s)
|
|
||||||
if e.Gotify != nil {
|
func (e *env) SetClient(client network.Client) {
|
||||||
e.Gotify.Notify("Error", 3, s)
|
e.client = client
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func (e *env) SetGotify(gotify admin.Gotify) {
|
||||||
// FatalOnError calls Fatal if the error is not nil.
|
e.gotify = gotify
|
||||||
func (e *Env) FatalOnError(err error) {
|
}
|
||||||
if err != nil {
|
|
||||||
e.Fatal(err)
|
func (e *env) SetDb(db data.Database) {
|
||||||
}
|
e.db = db
|
||||||
}
|
}
|
||||||
|
|
||||||
// shutdown cleanly exits the program by closing all connections,
|
// Notify sends a notification to the Gotify server.
|
||||||
// databases and syncing the loggers.
|
func (e *env) Notify(priority int, messageArgs ...interface{}) {
|
||||||
func (e *Env) shutdown() (exitCode int) {
|
if e.gotify == nil {
|
||||||
defer logging.Sync()
|
return
|
||||||
e.Client.Close()
|
}
|
||||||
if e.SQL != nil {
|
if err := e.gotify.Notify("DDNS Updater", priority, messageArgs...); err != nil {
|
||||||
err := e.SQL.Close()
|
e.logger.Error(err)
|
||||||
if err != nil {
|
}
|
||||||
logging.Err(err)
|
}
|
||||||
exitCode = 1
|
|
||||||
}
|
// Info logs a message and sends a notification to the Gotify server.
|
||||||
}
|
func (e *env) Info(messageArgs ...interface{}) {
|
||||||
return exitCode
|
e.logger.Info(messageArgs...)
|
||||||
}
|
e.Notify(1, messageArgs...)
|
||||||
|
}
|
||||||
// ShutdownFromSignal logs a warning, sends a notification to Gotify and shutdowns
|
|
||||||
// the program cleanly when a OS level signal is received. It should be passed as a
|
// Warn logs a message and sends a notification to the Gotify server.
|
||||||
// callback to a function which would catch such signal.
|
func (e *env) Warn(messageArgs ...interface{}) {
|
||||||
func (e *Env) ShutdownFromSignal(signal string) (exitCode int) {
|
e.logger.Warn(messageArgs...)
|
||||||
logging.Warnf("Program stopped with signal %s", signal)
|
e.Notify(2, messageArgs...)
|
||||||
if e.Gotify != nil {
|
}
|
||||||
e.Gotify.Notify("Program stopped", 1, "Caught OS signal "+signal)
|
|
||||||
}
|
// CheckError logs an error and sends a notification to the Gotify server
|
||||||
return e.shutdown()
|
// if the error is not nil.
|
||||||
}
|
func (e *env) CheckError(err error) {
|
||||||
|
if err == nil {
|
||||||
// Fatal logs an error, sends a notification to Gotify and shutdowns the program.
|
return
|
||||||
// It exits the program with an exit code of 1.
|
}
|
||||||
func (e *Env) Fatal(message interface{}) {
|
s := err.Error()
|
||||||
s := fmt.Sprintf("%s", message)
|
e.logger.Error(s)
|
||||||
logging.Error(s)
|
if len(s) > 100 {
|
||||||
if e.Gotify != nil {
|
s = s[:100] + "..." // trim down message for notification
|
||||||
e.Gotify.Notify("Fatal error", 4, s)
|
}
|
||||||
}
|
e.Notify(3, s)
|
||||||
e.shutdown()
|
}
|
||||||
os.Exit(1)
|
|
||||||
}
|
// FatalOnError calls Fatal if the error is not nil.
|
||||||
|
func (e *env) FatalOnError(err error) {
|
||||||
|
if err != nil {
|
||||||
|
e.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown cleanly exits the program by closing all connections,
|
||||||
|
// databases and syncing the loggers.
|
||||||
|
func (e *env) Shutdown() (exitCode int) {
|
||||||
|
defer func() {
|
||||||
|
if err := e.logger.Sync(); err != nil {
|
||||||
|
exitCode = 99
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if e.client != nil {
|
||||||
|
e.client.Close()
|
||||||
|
}
|
||||||
|
if e.db != nil {
|
||||||
|
if err := e.db.Close(); err != nil {
|
||||||
|
e.logger.Error(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShutdownFromSignal logs a warning, sends a notification to Gotify and shutdowns
|
||||||
|
// the program cleanly when a OS level signal is received. It should be passed as a
|
||||||
|
// callback to a function which would catch such signal.
|
||||||
|
func (e *env) ShutdownFromSignal(signal string) (exitCode int) {
|
||||||
|
e.logger.Warn("Program stopped with signal %q", signal)
|
||||||
|
e.Notify(1, "Caught OS signal %q", signal)
|
||||||
|
return e.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs an error, sends a notification to Gotify and shutdowns the program.
|
||||||
|
// It exits the program with an exit code of 1.
|
||||||
|
func (e *env) Fatal(messageArgs ...interface{}) {
|
||||||
|
e.logger.Error(messageArgs...)
|
||||||
|
e.Notify(4, messageArgs...)
|
||||||
|
_ = e.Shutdown()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|||||||
64
internal/handlers/handlers.go
Normal file
64
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/data"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/html"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
"github.com/qdm12/golibs/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler contains a handler function
|
||||||
|
type Handler interface {
|
||||||
|
GetHandlerFunc() http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
rootURL string
|
||||||
|
UIDir string
|
||||||
|
db data.Database
|
||||||
|
logger logging.Logger
|
||||||
|
forceUpdate func()
|
||||||
|
onError func(err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns a Handler object
|
||||||
|
func NewHandler(rootURL, UIDir string, db data.Database, logger logging.Logger,
|
||||||
|
forceUpdate func(), onError func(err error)) Handler {
|
||||||
|
return &handler{
|
||||||
|
rootURL: rootURL,
|
||||||
|
UIDir: UIDir,
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
forceUpdate: forceUpdate,
|
||||||
|
onError: onError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHandlerFunc returns a router with all the necessary routes configured
|
||||||
|
func (h *handler) GetHandlerFunc() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.logger.Info("received HTTP request at %s", r.RequestURI)
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/":
|
||||||
|
// TODO: Forms to change existing updates or add some
|
||||||
|
t := template.Must(template.ParseFiles(h.UIDir + "/ui/index.html"))
|
||||||
|
var htmlData models.HTMLData
|
||||||
|
for _, record := range h.db.SelectAll() {
|
||||||
|
row := html.ConvertRecord(record)
|
||||||
|
htmlData.Rows = append(htmlData.Rows, row)
|
||||||
|
}
|
||||||
|
if err := t.ExecuteTemplate(w, "index.html", htmlData); err != nil {
|
||||||
|
h.logger.Warn(err)
|
||||||
|
fmt.Fprint(w, "An error occurred creating this webpage")
|
||||||
|
}
|
||||||
|
case r.Method == http.MethodGet && r.RequestURI == h.rootURL+"/update":
|
||||||
|
h.logger.Info("Update started manually")
|
||||||
|
h.forceUpdate()
|
||||||
|
http.Redirect(w, r, h.rootURL, 301)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/healthcheck/healthcheck.go
Normal file
45
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/data"
|
||||||
|
"github.com/qdm12/golibs/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lookupIPFunc func(host string) ([]net.IP, error)
|
||||||
|
|
||||||
|
// IsHealthy checks all the records were updated successfully and returns an error if not
|
||||||
|
func IsHealthy(db data.Database, lookupIP lookupIPFunc, logger logging.Logger) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("unhealthy: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
records := db.SelectAll()
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Status == constants.FAIL {
|
||||||
|
return fmt.Errorf("%s", record.String())
|
||||||
|
} else if record.Settings.NoDNSLookup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lookedUpIPs, err := lookupIP(record.Settings.BuildDomainName())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentIP := record.History.GetCurrentIP()
|
||||||
|
if currentIP == nil {
|
||||||
|
return fmt.Errorf("no set IP address found")
|
||||||
|
}
|
||||||
|
for _, lookedUpIP := range lookedUpIPs {
|
||||||
|
if !lookedUpIP.Equal(currentIP) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"lookup IP address of %s is %s instead of %s",
|
||||||
|
record.Settings.BuildDomainName(), lookedUpIP, currentIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
internal/html/html.go
Normal file
103
internal/html/html.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConvertRecord(record models.Record) models.HTMLRow {
|
||||||
|
row := models.HTMLRow{
|
||||||
|
Domain: convertDomain(record.Settings.BuildDomainName()),
|
||||||
|
Host: record.Settings.Host,
|
||||||
|
Provider: convertProvider(record.Settings.Provider),
|
||||||
|
IPMethod: convertIPMethod(record.Settings.IPMethod, record.Settings.Provider),
|
||||||
|
}
|
||||||
|
message := record.Message
|
||||||
|
if record.Status == constants.UPTODATE {
|
||||||
|
message = "no IP change for " + record.History.GetDurationSinceSuccess()
|
||||||
|
}
|
||||||
|
if len(message) > 0 {
|
||||||
|
message = fmt.Sprintf("(%s)", message)
|
||||||
|
}
|
||||||
|
if len(record.Status) == 0 {
|
||||||
|
row.Status = "N/A"
|
||||||
|
} else {
|
||||||
|
row.Status = fmt.Sprintf("%s %s, %s",
|
||||||
|
convertStatus(record.Status),
|
||||||
|
message,
|
||||||
|
time.Since(record.Time).Round(time.Second).String()+" ago")
|
||||||
|
}
|
||||||
|
currentIP := record.History.GetCurrentIP()
|
||||||
|
if currentIP != nil {
|
||||||
|
row.CurrentIP = `<a href="https://ipinfo.io/"` + currentIP.String() + `\>` + currentIP.String() + "</a>"
|
||||||
|
} else {
|
||||||
|
row.CurrentIP = "N/A"
|
||||||
|
}
|
||||||
|
previousIPs := record.History.GetPreviousIPs()
|
||||||
|
row.PreviousIPs = "N/A"
|
||||||
|
if len(previousIPs) > 0 {
|
||||||
|
var previousIPsStr []string
|
||||||
|
const maxPreviousIPs = 2
|
||||||
|
for i, previousIP := range previousIPs {
|
||||||
|
if i == maxPreviousIPs {
|
||||||
|
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
previousIPsStr = append(previousIPsStr, previousIP.String())
|
||||||
|
}
|
||||||
|
row.PreviousIPs = strings.Join(previousIPsStr, ", ")
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertStatus(status models.Status) string {
|
||||||
|
switch status {
|
||||||
|
case constants.SUCCESS:
|
||||||
|
return constants.HTML_SUCCESS
|
||||||
|
case constants.FAIL:
|
||||||
|
return constants.HTML_FAIL
|
||||||
|
case constants.UPTODATE:
|
||||||
|
return constants.HTML_UPTODATE
|
||||||
|
case constants.UPDATING:
|
||||||
|
return constants.HTML_UPDATING
|
||||||
|
default:
|
||||||
|
return "Unknown status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertProvider(provider models.Provider) string {
|
||||||
|
switch provider {
|
||||||
|
case constants.PROVIDERNAMECHEAP:
|
||||||
|
return "<a href=\"https://namecheap.com\">Namecheap</a>"
|
||||||
|
case constants.PROVIDERGODADDY:
|
||||||
|
return "<a href=\"https://godaddy.com\">GoDaddy</a>"
|
||||||
|
case constants.PROVIDERDUCKDNS:
|
||||||
|
return "<a href=\"https://duckdns.org\">DuckDNS</a>"
|
||||||
|
case constants.PROVIDERDREAMHOST:
|
||||||
|
return "<a href=\"https://https://www.dreamhost.com/\">Dreamhost</a>"
|
||||||
|
default:
|
||||||
|
return string(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIPMethod(IPMethod models.IPMethod, provider models.Provider) string {
|
||||||
|
// TODO map to icons
|
||||||
|
switch IPMethod {
|
||||||
|
case constants.IPMETHODPROVIDER:
|
||||||
|
return convertProvider(provider)
|
||||||
|
case constants.IPMETHODGOOGLE:
|
||||||
|
return "<a href=\"https://google.com/search?q=ip\">Google</a>"
|
||||||
|
case constants.IPMETHODOPENDNS:
|
||||||
|
return "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
|
||||||
|
default:
|
||||||
|
return string(IPMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertDomain(domain string) string {
|
||||||
|
return "<a href=\"http://" + domain + "\">" + domain + "</a>"
|
||||||
|
}
|
||||||
10
internal/models/alias.go
Normal file
10
internal/models/alias.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Provider is a possible DNS provider
|
||||||
|
Provider string
|
||||||
|
// IPMethod is a method to obtain your public IP address
|
||||||
|
IPMethod string
|
||||||
|
// Status is the record config status
|
||||||
|
Status string
|
||||||
|
)
|
||||||
@@ -2,71 +2,80 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type historyType struct {
|
// History contains current and previous IP address for a particular record
|
||||||
ips []string // current and previous ips
|
// with the latest success time
|
||||||
tSuccess time.Time
|
type History struct {
|
||||||
sync.RWMutex
|
IPs []net.IP // current and previous ips
|
||||||
|
SuccessTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHistory(ips []string, tSuccess time.Time) historyType {
|
// GetPreviousIPs returns an antichronological list of previous
|
||||||
return historyType{
|
// IP addresses if there is any.
|
||||||
ips: ips,
|
func (h *History) GetPreviousIPs() []net.IP {
|
||||||
tSuccess: tSuccess,
|
if len(h.IPs) <= 1 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
IPs := make([]net.IP, len(h.IPs)-1)
|
||||||
|
for i := len(h.IPs) - 2; i >= 0; i-- {
|
||||||
|
IPs[i] = h.IPs[i]
|
||||||
|
}
|
||||||
|
return IPs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (history *historyType) PrependIP(ip string) {
|
// GetCurrentIP returns the current IP address (latest in history)
|
||||||
history.Lock()
|
func (h *History) GetCurrentIP() net.IP {
|
||||||
defer history.Unlock()
|
if len(h.IPs) < 1 {
|
||||||
history.ips = append([]string{ip}, history.ips...)
|
return nil
|
||||||
|
}
|
||||||
|
return h.IPs[len(h.IPs)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (history *historyType) SetTSuccess(t time.Time) {
|
func (h *History) GetDurationSinceSuccess() string {
|
||||||
history.Lock()
|
duration := time.Since(h.SuccessTime)
|
||||||
defer history.Unlock()
|
switch {
|
||||||
history.tSuccess = t
|
case duration < time.Minute:
|
||||||
}
|
|
||||||
|
|
||||||
func (history *historyType) GetIPs() []string {
|
|
||||||
history.RLock()
|
|
||||||
defer history.RUnlock()
|
|
||||||
return history.ips
|
|
||||||
}
|
|
||||||
|
|
||||||
func (history *historyType) GetTSuccessDuration() string {
|
|
||||||
history.RLock()
|
|
||||||
defer history.RUnlock()
|
|
||||||
return durationString(history.tSuccess)
|
|
||||||
}
|
|
||||||
|
|
||||||
func durationString(t time.Time) string {
|
|
||||||
duration := time.Since(t)
|
|
||||||
if duration < time.Minute {
|
|
||||||
return fmt.Sprintf("%ds", int(duration.Round(time.Second).Seconds()))
|
return fmt.Sprintf("%ds", int(duration.Round(time.Second).Seconds()))
|
||||||
} else if duration < time.Hour {
|
case duration < time.Hour:
|
||||||
return fmt.Sprintf("%dm", int(duration.Round(time.Minute).Minutes()))
|
return fmt.Sprintf("%dm", int(duration.Round(time.Minute).Minutes()))
|
||||||
} else if duration < 24*time.Hour {
|
case duration < 24*time.Hour:
|
||||||
return fmt.Sprintf("%dh", int(duration.Round(time.Hour).Hours()))
|
return fmt.Sprintf("%dh", int(duration.Round(time.Hour).Hours()))
|
||||||
} else {
|
default:
|
||||||
return fmt.Sprintf("%dd", int(duration.Round(time.Hour*24).Hours()/24))
|
return fmt.Sprintf("%dd", int(duration.Round(time.Hour*24).Hours()/24))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (history *historyType) String() (s string) {
|
func (h *History) String() (s string) {
|
||||||
history.RLock()
|
currentIP := h.GetCurrentIP()
|
||||||
defer history.RUnlock()
|
if currentIP == nil {
|
||||||
if len(history.ips) > 0 {
|
return ""
|
||||||
s += "Last success update: " + history.tSuccess.Format("2006-01-02 15:04:05 MST") + "; Current and previous IPs: "
|
|
||||||
for i := range history.ips {
|
|
||||||
s += history.ips[i]
|
|
||||||
if i != len(history.ips)-1 {
|
|
||||||
s += ","
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s
|
successTime := h.SuccessTime
|
||||||
|
previousIPs := h.GetPreviousIPs()
|
||||||
|
if len(previousIPs) == 0 {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Last success update: %s; IP: %s",
|
||||||
|
successTime.Format("2006-01-02 15:04:05 MST"),
|
||||||
|
currentIP.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const maxDisplay = 4
|
||||||
|
previousIPsStr := []string{}
|
||||||
|
for i, IP := range previousIPs {
|
||||||
|
if i == maxDisplay {
|
||||||
|
previousIPsStr = append(previousIPsStr, fmt.Sprintf("...(%d more)", len(previousIPs)-maxDisplay))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
previousIPsStr = append(previousIPsStr, IP.String())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Last success update: %s; IP: %s; Previous IPs: %s",
|
||||||
|
successTime.Format("2006-01-02 15:04:05 MST"),
|
||||||
|
currentIP.String(),
|
||||||
|
strings.Join(previousIPsStr, ","),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,11 @@ type HTMLData struct {
|
|||||||
// HTMLRow contains HTML fields to be rendered
|
// HTMLRow contains HTML fields to be rendered
|
||||||
// It is exported so that the HTML template engine can render it.
|
// It is exported so that the HTML template engine can render it.
|
||||||
type HTMLRow struct {
|
type HTMLRow struct {
|
||||||
Domain string
|
Domain string
|
||||||
Host string
|
Host string
|
||||||
Provider string
|
Provider string
|
||||||
IPMethod string
|
IPMethod string
|
||||||
Status string
|
Status string
|
||||||
IP string // current set ip
|
CurrentIP string
|
||||||
IPs []string // previous ips
|
PreviousIPs string
|
||||||
}
|
|
||||||
|
|
||||||
// ToHTML converts all the update record configs to HTML data ready to be templated
|
|
||||||
func ToHTML(recordsConfigs []RecordConfigType) (htmlData HTMLData) {
|
|
||||||
for i := range recordsConfigs {
|
|
||||||
htmlData.Rows = append(htmlData.Rows, recordsConfigs[i].toHTML())
|
|
||||||
}
|
|
||||||
return htmlData
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// IPMethodType is the enum type for all the possible IP methods
|
|
||||||
type IPMethodType string
|
|
||||||
|
|
||||||
// All possible IP methods values
|
|
||||||
const (
|
|
||||||
IPMETHODPROVIDER IPMethodType = "provider"
|
|
||||||
IPMETHODGOOGLE = "google"
|
|
||||||
IPMETHODOPENDNS = "opendns"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseIPMethod obtains the IP method from a string
|
|
||||||
func ParseIPMethod(s string) (IPMethodType, error) {
|
|
||||||
switch s {
|
|
||||||
case "provider", "google", "opendns":
|
|
||||||
return IPMethodType(s), nil
|
|
||||||
case "duckduckgo":
|
|
||||||
return "", fmt.Errorf("IP method duckduckgo no longer supported")
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("IP method %s not recognized", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProviderType is the enum type for the possible providers
|
|
||||||
type ProviderType uint8
|
|
||||||
|
|
||||||
// All possible provider values
|
|
||||||
const (
|
|
||||||
PROVIDERGODADDY ProviderType = iota
|
|
||||||
PROVIDERNAMECHEAP
|
|
||||||
PROVIDERDUCKDNS
|
|
||||||
PROVIDERDREAMHOST
|
|
||||||
PROVIDERCLOUDFLARE
|
|
||||||
PROVIDERNOIP
|
|
||||||
PROVIDERDNSPOD
|
|
||||||
)
|
|
||||||
|
|
||||||
func (provider ProviderType) String() string {
|
|
||||||
switch provider {
|
|
||||||
case PROVIDERGODADDY:
|
|
||||||
return "godaddy"
|
|
||||||
case PROVIDERNAMECHEAP:
|
|
||||||
return "namecheap"
|
|
||||||
case PROVIDERDUCKDNS:
|
|
||||||
return "duckdns"
|
|
||||||
case PROVIDERDREAMHOST:
|
|
||||||
return "dreamhost"
|
|
||||||
case PROVIDERCLOUDFLARE:
|
|
||||||
return "cloudflare"
|
|
||||||
case PROVIDERNOIP:
|
|
||||||
return "noip"
|
|
||||||
case PROVIDERDNSPOD:
|
|
||||||
return "dnspod"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseProvider obtains the provider from a string
|
|
||||||
func ParseProvider(s string) (ProviderType, error) {
|
|
||||||
switch s {
|
|
||||||
case "godaddy":
|
|
||||||
return PROVIDERGODADDY, nil
|
|
||||||
case "namecheap":
|
|
||||||
return PROVIDERNAMECHEAP, nil
|
|
||||||
case "duckdns":
|
|
||||||
return PROVIDERDUCKDNS, nil
|
|
||||||
case "dreamhost":
|
|
||||||
return PROVIDERDREAMHOST, nil
|
|
||||||
case "cloudflare":
|
|
||||||
return PROVIDERCLOUDFLARE, nil
|
|
||||||
case "noip":
|
|
||||||
return PROVIDERNOIP, nil
|
|
||||||
case "dnspod":
|
|
||||||
return PROVIDERDNSPOD, nil
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("Provider %s not recognized", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
35
internal/models/record.go
Normal file
35
internal/models/record.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record contains all the information to update and display a DNS record
|
||||||
|
type Record struct { // internal
|
||||||
|
Settings Settings // fixed
|
||||||
|
History History // past information
|
||||||
|
Status Status
|
||||||
|
Message string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecord returns a new Record with settings and some history
|
||||||
|
func NewRecord(settings Settings, IPs []net.IP, successTime time.Time) Record {
|
||||||
|
return Record{
|
||||||
|
Settings: settings,
|
||||||
|
History: History{
|
||||||
|
IPs: IPs,
|
||||||
|
SuccessTime: successTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Record) String() string {
|
||||||
|
status := string(r.Status)
|
||||||
|
if len(r.Message) > 0 {
|
||||||
|
status += " (" + r.Message + ")"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s %s; %s", r.Settings.String(), status, r.Time.Format("2006-01-02 15:04:05 MST"), r.History.String())
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecordConfigType contains all the information to update and display a DNS record
|
|
||||||
type RecordConfigType struct { // internal
|
|
||||||
Settings SettingsType // fixed
|
|
||||||
Status statusType // changes for each update
|
|
||||||
History historyType // past information
|
|
||||||
IsUpdating sync.Mutex // just to wait for an update to finish on chQuit signaling
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRecordConfig returns a new recordConfig with settings
|
|
||||||
func NewRecordConfig(settings SettingsType, ips []string, tSuccess time.Time) RecordConfigType {
|
|
||||||
return RecordConfigType{
|
|
||||||
Settings: settings,
|
|
||||||
History: newHistory(ips, tSuccess),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conf *RecordConfigType) String() string {
|
|
||||||
return conf.Settings.String() + ": " + conf.Status.String() + "; " + conf.History.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conf *RecordConfigType) toHTML() HTMLRow {
|
|
||||||
row := HTMLRow{
|
|
||||||
Domain: conf.Settings.getHTMLDomain(),
|
|
||||||
Host: conf.Settings.Host,
|
|
||||||
Provider: conf.Settings.getHTMLProvider(),
|
|
||||||
IPMethod: conf.Settings.getHTMLIPMethod(),
|
|
||||||
}
|
|
||||||
if conf.Status.code == UPTODATE {
|
|
||||||
conf.Status.message = "No IP change for " + conf.History.GetTSuccessDuration()
|
|
||||||
}
|
|
||||||
row.Status = conf.Status.toHTML()
|
|
||||||
ips := conf.History.GetIPs()
|
|
||||||
latestIP := ips[0]
|
|
||||||
if len(ips) > 0 {
|
|
||||||
row.IP = "<a href=\"https://ipinfo.io/" + latestIP + "\">" + latestIP + "</a>"
|
|
||||||
} else {
|
|
||||||
row.IP = "N/A"
|
|
||||||
}
|
|
||||||
if len(ips) > 1 {
|
|
||||||
for i, historicIP := range ips[1:] {
|
|
||||||
if i != len(ips[1:])-1 { // not the last one
|
|
||||||
historicIP += ", "
|
|
||||||
}
|
|
||||||
row.IPs = append(row.IPs, historicIP)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
row.IPs = []string{"N/A"}
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
@@ -2,42 +2,15 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/qdm12/golibs/verification"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// Settings contains the elements to update the DNS record
|
||||||
regexGoDaddyKey = `[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}`
|
type Settings struct {
|
||||||
regexGodaddySecret = `[A-Za-z0-9]{22}`
|
|
||||||
regexDuckDNSToken = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}`
|
|
||||||
regexNamecheapPassword = `[a-f0-9]{32}`
|
|
||||||
regexDreamhostKey = `[a-zA-Z0-9]{16}`
|
|
||||||
regexCloudflareKey = `[a-zA-Z0-9]+`
|
|
||||||
regexCloudflareToken = `[a-zA-Z0-9_]{40}`
|
|
||||||
regexCloudflareUserServiceKey = `v1\.0.+`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regex MatchString functions
|
|
||||||
var (
|
|
||||||
matchGodaddyKey = regexp.MustCompile("^" + regexGoDaddyKey + "$").MatchString
|
|
||||||
matchGodaddySecret = regexp.MustCompile("^" + regexGodaddySecret + "$").MatchString
|
|
||||||
matchDuckDNSToken = regexp.MustCompile("^" + regexDuckDNSToken + "$").MatchString
|
|
||||||
matchNamecheapPassword = regexp.MustCompile("^" + regexNamecheapPassword + "$").MatchString
|
|
||||||
matchDreamhostKey = regexp.MustCompile("^" + regexDreamhostKey + "$").MatchString
|
|
||||||
matchCloudflareKey = regexp.MustCompile("^" + regexCloudflareKey + "$").MatchString
|
|
||||||
matchCloudflareToken = regexp.MustCompile("^" + regexCloudflareToken + "$").MatchString
|
|
||||||
matchCloudflareUserServiceKey = regexp.MustCompile("^" + regexCloudflareUserServiceKey + "$").MatchString
|
|
||||||
)
|
|
||||||
|
|
||||||
// SettingsType contains the elements to update the DNS record
|
|
||||||
type SettingsType struct {
|
|
||||||
Domain string
|
Domain string
|
||||||
Host string
|
Host string
|
||||||
Provider ProviderType
|
Provider Provider
|
||||||
IPmethod IPMethodType
|
IPMethod IPMethod
|
||||||
Delay time.Duration
|
Delay time.Duration
|
||||||
NoDNSLookup bool
|
NoDNSLookup bool
|
||||||
// Provider dependent fields
|
// Provider dependent fields
|
||||||
@@ -54,7 +27,7 @@ type SettingsType struct {
|
|||||||
Username string // NoIP only
|
Username string // NoIP only
|
||||||
}
|
}
|
||||||
|
|
||||||
func (settings *SettingsType) String() string {
|
func (settings *Settings) String() string {
|
||||||
b, _ := json.Marshal(
|
b, _ := json.Marshal(
|
||||||
struct {
|
struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
@@ -63,14 +36,14 @@ func (settings *SettingsType) String() string {
|
|||||||
}{
|
}{
|
||||||
settings.Domain,
|
settings.Domain,
|
||||||
settings.Host,
|
settings.Host,
|
||||||
settings.Provider.String(),
|
string(settings.Provider),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildDomainName builds the domain name from the domain and the host of the settings
|
// BuildDomainName builds the domain name from the domain and the host of the settings
|
||||||
func (settings *SettingsType) BuildDomainName() string {
|
func (settings *Settings) BuildDomainName() string {
|
||||||
if settings.Host == "@" {
|
if settings.Host == "@" {
|
||||||
return settings.Domain
|
return settings.Domain
|
||||||
} else if settings.Host == "*" {
|
} else if settings.Host == "*" {
|
||||||
@@ -79,117 +52,3 @@ func (settings *SettingsType) BuildDomainName() string {
|
|||||||
return settings.Host + "." + settings.Domain
|
return settings.Host + "." + settings.Domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (settings *SettingsType) getHTMLDomain() string {
|
|
||||||
return "<a href=\"http://" + settings.BuildDomainName() + "\">" + settings.Domain + "</a>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (settings *SettingsType) getHTMLProvider() string {
|
|
||||||
switch settings.Provider {
|
|
||||||
case PROVIDERNAMECHEAP:
|
|
||||||
return "<a href=\"https://namecheap.com\">Namecheap</a>"
|
|
||||||
case PROVIDERGODADDY:
|
|
||||||
return "<a href=\"https://godaddy.com\">GoDaddy</a>"
|
|
||||||
case PROVIDERDUCKDNS:
|
|
||||||
return "<a href=\"https://duckdns.org\">DuckDNS</a>"
|
|
||||||
case PROVIDERDREAMHOST:
|
|
||||||
return "<a href=\"https://https://www.dreamhost.com/\">Dreamhost</a>"
|
|
||||||
default:
|
|
||||||
return settings.Provider.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO map to icons
|
|
||||||
func (settings *SettingsType) getHTMLIPMethod() string {
|
|
||||||
switch settings.IPmethod {
|
|
||||||
case IPMETHODPROVIDER:
|
|
||||||
return settings.getHTMLProvider()
|
|
||||||
case IPMETHODGOOGLE:
|
|
||||||
return "<a href=\"https://google.com/search?q=ip\">Google</a>"
|
|
||||||
case IPMETHODOPENDNS:
|
|
||||||
return "<a href=\"https://diagnostic.opendns.com/myip\">OpenDNS</a>"
|
|
||||||
default:
|
|
||||||
return string(settings.IPmethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify verifies all the settings provided are valid
|
|
||||||
func (settings *SettingsType) Verify() error {
|
|
||||||
if !verification.MatchDomain(settings.Domain) {
|
|
||||||
return fmt.Errorf("invalid domain name format for settings %s", settings)
|
|
||||||
} else if len(settings.Host) == 0 {
|
|
||||||
return fmt.Errorf("host cannot be empty for settings %s", settings)
|
|
||||||
}
|
|
||||||
switch settings.Provider {
|
|
||||||
case PROVIDERNAMECHEAP:
|
|
||||||
if !matchNamecheapPassword(settings.Password) {
|
|
||||||
return fmt.Errorf("invalid password format for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERGODADDY:
|
|
||||||
if !matchGodaddyKey(settings.Key) {
|
|
||||||
return fmt.Errorf("invalid key format for settings %s", settings)
|
|
||||||
} else if !matchGodaddySecret(settings.Secret) {
|
|
||||||
return fmt.Errorf("invalid secret format for settings %s", settings)
|
|
||||||
} else if settings.IPmethod == IPMETHODPROVIDER {
|
|
||||||
return fmt.Errorf("unsupported IP update method for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERDUCKDNS:
|
|
||||||
if !matchDuckDNSToken(settings.Token) {
|
|
||||||
return fmt.Errorf("invalid token format for settings %s", settings)
|
|
||||||
} else if settings.Host != "@" {
|
|
||||||
return fmt.Errorf("host can only be \"@\" for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERDREAMHOST:
|
|
||||||
if !matchDreamhostKey(settings.Key) {
|
|
||||||
return fmt.Errorf("invalid key format for settings %s", settings)
|
|
||||||
} else if settings.Host != "@" {
|
|
||||||
return fmt.Errorf("host can only be \"@\" for settings %s", settings)
|
|
||||||
} else if settings.IPmethod == IPMETHODPROVIDER {
|
|
||||||
return fmt.Errorf("unsupported IP update method for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERCLOUDFLARE:
|
|
||||||
if settings.Key != "" { // email and key must be provided
|
|
||||||
if !matchCloudflareKey(settings.Key) {
|
|
||||||
return fmt.Errorf("invalid key format for settings %s", settings)
|
|
||||||
} else if !verification.MatchEmail(settings.Email) {
|
|
||||||
return fmt.Errorf("invalid email format for settings %s", settings)
|
|
||||||
}
|
|
||||||
} else if settings.UserServiceKey != "" { // only user service key
|
|
||||||
if !matchCloudflareUserServiceKey(settings.UserServiceKey) {
|
|
||||||
return fmt.Errorf("invalid user service key format for settings %s", settings)
|
|
||||||
}
|
|
||||||
} else { // otherwise use an API token
|
|
||||||
if !matchCloudflareToken(settings.Token) {
|
|
||||||
return fmt.Errorf("invalid API token key format for settings %s", settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(settings.ZoneIdentifier) == 0 {
|
|
||||||
return fmt.Errorf("zone identifier cannot be empty to settings %s", settings)
|
|
||||||
} else if len(settings.Identifier) == 0 {
|
|
||||||
return fmt.Errorf("identifier cannot be empty to settings %s", settings)
|
|
||||||
} else if settings.IPmethod == IPMETHODPROVIDER {
|
|
||||||
return fmt.Errorf("unsupported IP update method for settings %s", settings)
|
|
||||||
} else if settings.Ttl == 0 {
|
|
||||||
return fmt.Errorf("TTL cannot be left to 0 for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERNOIP:
|
|
||||||
if len(settings.Username) == 0 {
|
|
||||||
return fmt.Errorf("username cannot be empty for settings %s", settings)
|
|
||||||
} else if len(settings.Username) > 50 {
|
|
||||||
return fmt.Errorf("username cannot be longer than 50 characters for settings %s", settings)
|
|
||||||
} else if len(settings.Password) == 0 {
|
|
||||||
return fmt.Errorf("password cannot be empty for settings %s", settings)
|
|
||||||
} else if settings.Host == "*" {
|
|
||||||
return fmt.Errorf("host cannot be * for settings %s", settings)
|
|
||||||
}
|
|
||||||
case PROVIDERDNSPOD:
|
|
||||||
if len(settings.Token) == 0 {
|
|
||||||
return fmt.Errorf("token cannot be empty for settings %s", settings)
|
|
||||||
} else if settings.IPmethod == IPMETHODPROVIDER {
|
|
||||||
return fmt.Errorf("unsupported IP update method for settings %s", settings)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("provider \"%s\" is not supported", settings.Provider)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type statusCode uint8
|
|
||||||
|
|
||||||
// Update possible status codes: FAIL, SUCCESS, UPTODATE or UPDATING
|
|
||||||
const (
|
|
||||||
FAIL statusCode = iota
|
|
||||||
SUCCESS
|
|
||||||
UPTODATE
|
|
||||||
)
|
|
||||||
|
|
||||||
func (code *statusCode) String() (s string) {
|
|
||||||
switch *code {
|
|
||||||
case SUCCESS:
|
|
||||||
return "Success"
|
|
||||||
case FAIL:
|
|
||||||
return "Failure"
|
|
||||||
case UPTODATE:
|
|
||||||
return "Up to date"
|
|
||||||
default:
|
|
||||||
return "Unknown status code!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (code *statusCode) toHTML() (s string) {
|
|
||||||
switch *code {
|
|
||||||
case SUCCESS:
|
|
||||||
return `<font color="green">Success</font>`
|
|
||||||
case FAIL:
|
|
||||||
return `<font color="red">Failure</font>`
|
|
||||||
case UPTODATE:
|
|
||||||
return `<font color="#00CC66">Up to date</font>`
|
|
||||||
default:
|
|
||||||
return `<font color="red">Unknown status code!</font>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusType struct {
|
|
||||||
code statusCode
|
|
||||||
message string
|
|
||||||
time time.Time
|
|
||||||
sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) SetTime(t time.Time) {
|
|
||||||
status.Lock()
|
|
||||||
defer status.Unlock()
|
|
||||||
status.time = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) SetCode(code statusCode) {
|
|
||||||
status.Lock()
|
|
||||||
defer status.Unlock()
|
|
||||||
status.code = code
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) SetMessage(format string, a ...interface{}) {
|
|
||||||
status.Lock()
|
|
||||||
defer status.Unlock()
|
|
||||||
status.message = fmt.Sprintf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) GetTime() time.Time {
|
|
||||||
status.RLock()
|
|
||||||
defer status.RUnlock()
|
|
||||||
return status.time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) GetCode() statusCode {
|
|
||||||
status.RLock()
|
|
||||||
defer status.RUnlock()
|
|
||||||
return status.code
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) GetMessage() string {
|
|
||||||
status.RLock()
|
|
||||||
defer status.RUnlock()
|
|
||||||
return status.message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) String() (s string) {
|
|
||||||
status.RLock()
|
|
||||||
defer status.RUnlock()
|
|
||||||
s += status.code.String()
|
|
||||||
if status.message != "" {
|
|
||||||
s += " (" + status.message + ")"
|
|
||||||
}
|
|
||||||
s += " at " + status.time.Format("2006-01-02 15:04:05 MST")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (status *statusType) toHTML() (s string) {
|
|
||||||
status.RLock()
|
|
||||||
defer status.RUnlock()
|
|
||||||
s += status.code.toHTML()
|
|
||||||
if status.message != "" {
|
|
||||||
s += " (" + status.message + ")"
|
|
||||||
}
|
|
||||||
s += ", " + time.Since(status.time).Round(time.Second).String() + " ago"
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,27 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
"github.com/qdm12/golibs/network"
|
"github.com/qdm12/golibs/network"
|
||||||
"github.com/qdm12/golibs/verification"
|
"github.com/qdm12/golibs/verification"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetPublicIP downloads a webpage and extracts the IP address from it
|
// GetPublicIP downloads a webpage and extracts the IP address from it
|
||||||
func GetPublicIP(client network.Client, URL string) (ip string, err error) {
|
func GetPublicIP(client network.Client, URL string) (ip net.IP, err error) {
|
||||||
content, status, err := client.GetContent(URL)
|
content, status, err := client.GetContent(URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ip, fmt.Errorf("cannot get public IP address from %s: %s", URL, err)
|
return nil, fmt.Errorf("cannot get public IP address from %s: %s", URL, err)
|
||||||
} else if status != 200 {
|
} else if status != 200 {
|
||||||
return ip, fmt.Errorf("cannot get public IP address from %s: HTTP status code %d", URL, status)
|
return nil, fmt.Errorf("cannot get public IP address from %s: HTTP status code %d", URL, status)
|
||||||
}
|
}
|
||||||
ips := verification.SearchIPv4(string(content))
|
ips := verification.NewVerifier().SearchIPv4(string(content))
|
||||||
if ips == nil {
|
if ips == nil {
|
||||||
return ip, fmt.Errorf("no public IP found at %s: %s", URL, err)
|
return nil, fmt.Errorf("no public IP found at %s: %s", URL, err)
|
||||||
|
}
|
||||||
|
ip = net.ParseIP(ips[0])
|
||||||
|
if ip == nil {
|
||||||
|
return nil, fmt.Errorf("Public IP address %q found at %s is not valid", ips[0], URL)
|
||||||
}
|
}
|
||||||
ip = ips[0]
|
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
|
|
||||||
// BuildHTTPPut is used for GoDaddy and Cloudflare only
|
// BuildHTTPPut is used for GoDaddy and Cloudflare only
|
||||||
func BuildHTTPPut(URL string, body interface{}) (request *http.Request, err error) {
|
func BuildHTTPPut(URL string, body interface{}) (request *http.Request, err error) {
|
||||||
jsonData, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
request, err = http.NewRequest(http.MethodPut, URL, bytes.NewBuffer(jsonData))
|
request, err = http.NewRequest(http.MethodPut, URL, bytes.NewBuffer(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
108
internal/params/consistency.go
Normal file
108
internal/params/consistency.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package params
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *params) isConsistent(settings models.Settings) error {
|
||||||
|
switch {
|
||||||
|
case !ipMethodIsValid(settings.IPMethod, constants.IPMethodChoices()):
|
||||||
|
return fmt.Errorf("IP method %q is not recognized", settings.IPMethod)
|
||||||
|
case !p.verifier.MatchDomain(settings.Domain):
|
||||||
|
return fmt.Errorf("invalid domain name format")
|
||||||
|
case len(settings.Host) == 0:
|
||||||
|
return fmt.Errorf("host cannot be empty")
|
||||||
|
}
|
||||||
|
switch settings.Provider {
|
||||||
|
case constants.PROVIDERNAMECHEAP:
|
||||||
|
if !constants.MatchNamecheapPassword(settings.Password) {
|
||||||
|
return fmt.Errorf("invalid password format")
|
||||||
|
}
|
||||||
|
case constants.PROVIDERGODADDY:
|
||||||
|
switch {
|
||||||
|
case !constants.MatchGodaddyKey(settings.Key):
|
||||||
|
return fmt.Errorf("invalid key format")
|
||||||
|
case !constants.MatchGodaddySecret(settings.Secret):
|
||||||
|
return fmt.Errorf("invalid secret format")
|
||||||
|
case settings.IPMethod == constants.IPMETHODPROVIDER:
|
||||||
|
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
|
||||||
|
}
|
||||||
|
case constants.PROVIDERDUCKDNS:
|
||||||
|
switch {
|
||||||
|
case !constants.MatchDuckDNSToken(settings.Token):
|
||||||
|
return fmt.Errorf("invalid token format")
|
||||||
|
case settings.Host != "@":
|
||||||
|
return fmt.Errorf(`host can only be "@"`)
|
||||||
|
}
|
||||||
|
case constants.PROVIDERDREAMHOST:
|
||||||
|
switch {
|
||||||
|
case !constants.MatchDreamhostKey(settings.Key):
|
||||||
|
return fmt.Errorf("invalid key format")
|
||||||
|
case settings.Host != "@":
|
||||||
|
return fmt.Errorf(`host can only be "@"`)
|
||||||
|
case settings.IPMethod == constants.IPMETHODPROVIDER:
|
||||||
|
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
|
||||||
|
}
|
||||||
|
case constants.PROVIDERCLOUDFLARE:
|
||||||
|
switch {
|
||||||
|
case len(settings.Key) > 0: // email and key must be provided
|
||||||
|
switch {
|
||||||
|
case !constants.MatchCloudflareKey(settings.Key):
|
||||||
|
return fmt.Errorf("invalid key format")
|
||||||
|
case !p.verifier.MatchEmail(settings.Email):
|
||||||
|
return fmt.Errorf("invalid email format")
|
||||||
|
}
|
||||||
|
case len(settings.UserServiceKey) > 0: // only user service key
|
||||||
|
if !constants.MatchCloudflareKey(settings.Key) {
|
||||||
|
return fmt.Errorf("invalid user service key format")
|
||||||
|
}
|
||||||
|
default: // API token only
|
||||||
|
if !constants.MatchCloudflareToken(settings.Token) {
|
||||||
|
return fmt.Errorf("invalid API token key format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(settings.ZoneIdentifier) == 0:
|
||||||
|
return fmt.Errorf("zone identifier cannot be empty")
|
||||||
|
case len(settings.Identifier) == 0:
|
||||||
|
return fmt.Errorf("identifier cannot be empty")
|
||||||
|
case settings.IPMethod == constants.IPMETHODPROVIDER:
|
||||||
|
return fmt.Errorf("unsupported IP update method %q", settings.IPMethod)
|
||||||
|
case settings.Ttl == 0:
|
||||||
|
return fmt.Errorf("TTL cannot be left to 0")
|
||||||
|
}
|
||||||
|
case constants.PROVIDERNOIP:
|
||||||
|
switch {
|
||||||
|
case len(settings.Username) == 0:
|
||||||
|
return fmt.Errorf("username cannot be empty")
|
||||||
|
case len(settings.Username) > 50:
|
||||||
|
return fmt.Errorf("username cannot be longer than 50 characters")
|
||||||
|
case len(settings.Password) == 0:
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
case settings.Host == "*":
|
||||||
|
return fmt.Errorf(`host cannot be "*"`)
|
||||||
|
}
|
||||||
|
case constants.PROVIDERDNSPOD:
|
||||||
|
switch {
|
||||||
|
case len(settings.Token) == 0:
|
||||||
|
return fmt.Errorf("token cannot be empty")
|
||||||
|
case settings.IPMethod == constants.IPMETHODPROVIDER:
|
||||||
|
return fmt.Errorf("unsupported IP update method")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("provider %q is not supported", settings.Provider)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipMethodIsValid(ipMethod models.IPMethod, possibilities []models.IPMethod) bool {
|
||||||
|
for i := range possibilities {
|
||||||
|
if ipMethod == possibilities[i] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,75 +3,55 @@ package params
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configType struct {
|
|
||||||
Settings []settingsType `json:"settings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type settingsType struct {
|
type settingsType struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
IPMethod string `json:"ip_method"`
|
IPMethod string `json:"ip_method"`
|
||||||
Delay time.Duration `json:"delay"`
|
Delay uint64 `json:"delay"`
|
||||||
NoDNSLookup bool `json:"no_dns_lookup"`
|
NoDNSLookup bool `json:"no_dns_lookup"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Password string `json:"password"` // Namecheap, NoIP only
|
Password string `json:"password"` // Namecheap, NoIP only
|
||||||
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
|
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
|
||||||
Secret string `json:"secret"` // GoDaddy only
|
Secret string `json:"secret"` // GoDaddy only
|
||||||
Token string `json:"token"` // DuckDNS only
|
Token string `json:"token"` // DuckDNS and Cloudflare only
|
||||||
Email string `json:"email"` // Cloudflare only
|
Email string `json:"email"` // Cloudflare only
|
||||||
Username string `json:"username"` // NoIP only
|
Username string `json:"username"` // NoIP only
|
||||||
UserServiceKey string `json:"user_service_key"` // Cloudflare only
|
UserServiceKey string `json:"user_service_key"` // Cloudflare only
|
||||||
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
|
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
|
||||||
Identifier string `json:"identifier"` // Cloudflare only
|
Identifier string `json:"identifier"` // Cloudflare only
|
||||||
Proxied bool `json:"proxied"` // Cloudflare only
|
Proxied bool `json:"proxied"` // Cloudflare only
|
||||||
Ttl uint `json:"ttl"` // Cloudflare only
|
Ttl uint `json:"ttl"` // Cloudflare only
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings obtain the update settings from config.json
|
// GetSettings obtain the update settings from config.json
|
||||||
func GetSettings(filePath string) (settings []models.SettingsType, warnings []string, err error) {
|
func (p *params) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
|
||||||
f, err := os.Open(filePath)
|
bytes, err := p.readFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
bytes, err := ioutil.ReadAll(f)
|
var config struct {
|
||||||
f.Close()
|
Settings []settingsType `json:"settings"`
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
}
|
||||||
var config configType
|
if err := json.Unmarshal(bytes, &config); err != nil {
|
||||||
err = json.Unmarshal(bytes, &config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, s := range config.Settings {
|
for _, s := range config.Settings {
|
||||||
provider, err := models.ParseProvider(s.Provider)
|
switch models.Provider(s.Provider) {
|
||||||
if err != nil {
|
case constants.PROVIDERDREAMHOST, constants.PROVIDERDUCKDNS:
|
||||||
warnings = append(warnings, err.Error())
|
s.Host = "@" // only choice available
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
IPMethod, err := models.ParseIPMethod(s.IPMethod)
|
setting := models.Settings{
|
||||||
if err != nil {
|
Provider: models.Provider(s.Provider),
|
||||||
warnings = append(warnings, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
delay := time.Second * s.Delay
|
|
||||||
host := s.Host
|
|
||||||
if provider == models.PROVIDERDREAMHOST || provider == models.PROVIDERDUCKDNS {
|
|
||||||
host = "@" // only one choice
|
|
||||||
}
|
|
||||||
setting := models.SettingsType{
|
|
||||||
Provider: provider,
|
|
||||||
Domain: s.Domain,
|
Domain: s.Domain,
|
||||||
Host: host,
|
Host: s.Host,
|
||||||
IPmethod: IPMethod,
|
IPMethod: models.IPMethod(s.IPMethod),
|
||||||
Delay: delay,
|
Delay: time.Second * time.Duration(s.Delay),
|
||||||
NoDNSLookup: s.NoDNSLookup,
|
NoDNSLookup: s.NoDNSLookup,
|
||||||
Password: s.Password,
|
Password: s.Password,
|
||||||
Key: s.Key,
|
Key: s.Key,
|
||||||
@@ -85,8 +65,8 @@ func GetSettings(filePath string) (settings []models.SettingsType, warnings []st
|
|||||||
Proxied: s.Proxied,
|
Proxied: s.Proxied,
|
||||||
Ttl: s.Ttl,
|
Ttl: s.Ttl,
|
||||||
}
|
}
|
||||||
if err := setting.Verify(); err != nil {
|
if err := p.isConsistent(setting); err != nil {
|
||||||
warnings = append(warnings, err.Error())
|
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
settings = append(settings, setting)
|
settings = append(settings, setting)
|
||||||
|
|||||||
@@ -1,11 +1,85 @@
|
|||||||
package params
|
package params
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
"github.com/qdm12/golibs/logging"
|
||||||
libparams "github.com/qdm12/golibs/params"
|
libparams "github.com/qdm12/golibs/params"
|
||||||
|
"github.com/qdm12/golibs/verification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ParamsReader interface {
|
||||||
|
GetSettings(filePath string) (settings []models.Settings, warnings []string, err error)
|
||||||
|
GetDataDir(currentDir string) (string, error)
|
||||||
|
GetListeningPort() (listeningPort, warning string, err error)
|
||||||
|
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
|
||||||
|
GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error)
|
||||||
|
GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error)
|
||||||
|
GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error)
|
||||||
|
GetDelay(setters ...libparams.GetEnvSetter) (duration time.Duration, err error)
|
||||||
|
GetExeDir() (dir string, err error)
|
||||||
|
GetHTTPTimeout() (duration time.Duration, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type params struct {
|
||||||
|
envParams libparams.EnvParams
|
||||||
|
verifier verification.Verifier
|
||||||
|
logger logging.Logger
|
||||||
|
readFile func(filename string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewParamsReader(logger logging.Logger) ParamsReader {
|
||||||
|
return ¶ms{
|
||||||
|
envParams: libparams.NewEnvParams(),
|
||||||
|
verifier: verification.NewVerifier(),
|
||||||
|
logger: logger,
|
||||||
|
readFile: ioutil.ReadFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDataDir obtains the data directory from the environment
|
// GetDataDir obtains the data directory from the environment
|
||||||
// variable DATADIR
|
// variable DATADIR
|
||||||
func GetDataDir(e libparams.EnvParams, dir string) (string, error) {
|
func (p *params) GetDataDir(currentDir string) (string, error) {
|
||||||
return e.GetEnv("DATADIR", libparams.Default(dir+"/data"))
|
return p.envParams.GetEnv("DATADIR", libparams.Default(currentDir+"/data"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetListeningPort() (listeningPort, warning string, err error) {
|
||||||
|
return p.envParams.GetListeningPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error) {
|
||||||
|
return p.envParams.GetLoggerConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error) {
|
||||||
|
return p.envParams.GetGotifyURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
|
||||||
|
return p.envParams.GetGotifyToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
|
||||||
|
return p.envParams.GetRootURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, err error) {
|
||||||
|
// Backward compatibility
|
||||||
|
n, err := p.envParams.GetEnvInt("DELAY", libparams.Compulsory()) // TODO change to PERIOD
|
||||||
|
if err == nil { // integer only, treated as seconds
|
||||||
|
p.logger.Warn("The value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n)
|
||||||
|
return time.Duration(n) * time.Second, nil
|
||||||
|
}
|
||||||
|
return p.envParams.GetDuration("DELAY", setters...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetExeDir() (dir string, err error) {
|
||||||
|
return p.envParams.GetExeDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *params) GetHTTPTimeout() (duration time.Duration, err error) {
|
||||||
|
return p.envParams.GetHTTPTimeout(libparams.Default("10s"))
|
||||||
}
|
}
|
||||||
|
|||||||
18
internal/persistence/interface.go
Normal file
18
internal/persistence/interface.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/persistence/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Database interface {
|
||||||
|
Close() error
|
||||||
|
StoreNewIP(domain, host string, ip net.IP) (err error)
|
||||||
|
GetIPs(domain, host string) (ips []net.IP, tNew time.Time, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLite(dataDir string) (Database, error) {
|
||||||
|
return sqlite.NewDatabase(dataDir)
|
||||||
|
}
|
||||||
34
internal/persistence/sqlite/database.go
Normal file
34
internal/persistence/sqlite/database.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type database struct {
|
||||||
|
sqlite *sql.DB
|
||||||
|
sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *database) Close() error {
|
||||||
|
return db.sqlite.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase opens or creates the database if necessary.
|
||||||
|
func NewDatabase(dataDir string) (*database, error) {
|
||||||
|
sqlite, err := sql.Open("sqlite3", dataDir+"/updates.db")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, err = sqlite.Exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS updates_ips (
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
t_new DATETIME NOT NULL,
|
||||||
|
t_last DATETIME NOT NULL,
|
||||||
|
current INTEGER DEFAULT 1 NOT NULL,
|
||||||
|
PRIMARY KEY(domain, host, ip, t_new)
|
||||||
|
);`)
|
||||||
|
return &database{sqlite: sqlite}, err
|
||||||
|
}
|
||||||
74
internal/persistence/sqlite/queries.go
Normal file
74
internal/persistence/sqlite/queries.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Access to SQLite is NOT thread safe so we use a mutex */
|
||||||
|
|
||||||
|
// StoreNewIP stores a new IP address for a certain
|
||||||
|
// domain and host.
|
||||||
|
func (db *database) StoreNewIP(domain, host string, ip net.IP) (err error) {
|
||||||
|
// Disable the current IP
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
_, err = db.sqlite.Exec(
|
||||||
|
`UPDATE updates_ips
|
||||||
|
SET current = 0
|
||||||
|
WHERE domain = ? AND host = ? AND current = 1`,
|
||||||
|
domain,
|
||||||
|
host,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Inserts new IP
|
||||||
|
_, err = db.sqlite.Exec(
|
||||||
|
`INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?);`,
|
||||||
|
domain,
|
||||||
|
host,
|
||||||
|
ip.String(),
|
||||||
|
time.Now(),
|
||||||
|
time.Now(), // unneeded but it's hard to modify tables in sqlite
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIPs gets all the IP addresses history for a certain domain and host, in the order
|
||||||
|
// from oldest to newest
|
||||||
|
func (db *database) GetIPs(domain, host string) (ips []net.IP, tNew time.Time, err error) {
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
rows, err := db.sqlite.Query(
|
||||||
|
`SELECT ip, t_new
|
||||||
|
FROM updates_ips
|
||||||
|
WHERE domain = ? AND host = ?
|
||||||
|
ORDER BY t_new ASC`,
|
||||||
|
domain,
|
||||||
|
host,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, tNew, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = rows.Close()
|
||||||
|
}()
|
||||||
|
for rows.Next() {
|
||||||
|
var ip string
|
||||||
|
var t time.Time
|
||||||
|
if err := rows.Scan(&ip, &t); err != nil {
|
||||||
|
return nil, tNew, err
|
||||||
|
}
|
||||||
|
if tNew.IsZero() {
|
||||||
|
tNew = t
|
||||||
|
}
|
||||||
|
ips = append(ips, net.ParseIP(ip))
|
||||||
|
}
|
||||||
|
if tNew.IsZero() {
|
||||||
|
tNew = time.Now()
|
||||||
|
}
|
||||||
|
return ips, tNew, rows.Err()
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func IsHealthy(recordsConfigs []models.RecordConfigType) error {
|
|
||||||
for i := range recordsConfigs {
|
|
||||||
if recordsConfigs[i].Status.GetCode() == models.FAIL {
|
|
||||||
return fmt.Errorf("%s", recordsConfigs[i].String())
|
|
||||||
}
|
|
||||||
if recordsConfigs[i].Settings.NoDNSLookup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lookupIPs, err := net.LookupIP(recordsConfigs[i].Settings.BuildDomainName())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
historyIPs := recordsConfigs[i].History.GetIPs()
|
|
||||||
if len(historyIPs) == 0 {
|
|
||||||
return fmt.Errorf("no set IP address found")
|
|
||||||
}
|
|
||||||
latestIP := historyIPs[0]
|
|
||||||
for _, lookupIP := range lookupIPs {
|
|
||||||
if lookupIP.String() != latestIP {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"lookup IP address of %s is %s, not %s",
|
|
||||||
recordsConfigs[i].Settings.BuildDomainName(),
|
|
||||||
lookupIP,
|
|
||||||
latestIP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
|
||||||
"github.com/qdm12/golibs/admin"
|
|
||||||
"github.com/qdm12/golibs/logging"
|
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
|
||||||
|
|
||||||
type indexParamsType struct {
|
|
||||||
dir string
|
|
||||||
recordsConfigs []models.RecordConfigType
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateParamsType struct {
|
|
||||||
rootURL string
|
|
||||||
forceCh chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRouter returns a router with all the necessary routes configured
|
|
||||||
func CreateRouter(rootURL, dir string, forceCh chan struct{}, recordsConfigs []models.RecordConfigType, gotify admin.Gotify) *httprouter.Router {
|
|
||||||
indexParams := indexParamsType{
|
|
||||||
dir: dir,
|
|
||||||
recordsConfigs: recordsConfigs,
|
|
||||||
}
|
|
||||||
updateParams := updateParamsType{
|
|
||||||
rootURL: rootURL,
|
|
||||||
forceCh: forceCh,
|
|
||||||
}
|
|
||||||
router := httprouter.New()
|
|
||||||
router.GET(rootURL+"/", indexParams.get)
|
|
||||||
router.GET(rootURL+"/update", updateParams.get)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *indexParamsType) get(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
|
|
||||||
// TODO: Forms to change existing updates or add some
|
|
||||||
t := template.Must(template.ParseFiles(params.dir + "/ui/index.html"))
|
|
||||||
htmlData := models.ToHTML(params.recordsConfigs)
|
|
||||||
err := t.ExecuteTemplate(w, "index.html", htmlData) // TODO Without pointer?
|
|
||||||
if err != nil {
|
|
||||||
logging.Warn(err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprint(w, "An error occurred creating this webpage")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *updateParamsType) get(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
||||||
params.forceCh <- struct{}{}
|
|
||||||
logging.Info("Update started manually")
|
|
||||||
http.Redirect(w, r, params.rootURL, 301)
|
|
||||||
}
|
|
||||||
51
internal/trigger/trigger.go
Normal file
51
internal/trigger/trigger.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package trigger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/update"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartUpdates starts periodic updates
|
||||||
|
func StartUpdates(ctx context.Context, updater update.Updater, idPeriodMapping map[int]time.Duration, onError func(err error)) (forceUpdate func()) {
|
||||||
|
errors := make(chan error)
|
||||||
|
triggers := make([]chan struct{}, len(idPeriodMapping))
|
||||||
|
for id, period := range idPeriodMapping {
|
||||||
|
triggers[id] = make(chan struct{})
|
||||||
|
go func(id int, period time.Duration) {
|
||||||
|
ticker := time.NewTicker(period)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-triggers[id]:
|
||||||
|
if err := updater.Update(id); err != nil {
|
||||||
|
errors <- err
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := updater.Update(id); err != nil {
|
||||||
|
errors <- err
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(id, period)
|
||||||
|
}
|
||||||
|
// collects errors only
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-errors:
|
||||||
|
onError(err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() {
|
||||||
|
for i := range triggers {
|
||||||
|
triggers[i] <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/update/cloudflare.go
Normal file
82
internal/update/cloudflare.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/network"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host, email, key, userServiceKey, token string, proxied bool, ttl uint, ip net.IP) (err error) {
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("IP address was not given to updater")
|
||||||
|
}
|
||||||
|
type cloudflarePutBody struct {
|
||||||
|
Type string `json:"type"` // forced to A
|
||||||
|
Name string `json:"name"` // DNS record name i.e. example.com
|
||||||
|
Content string `json:"content"` // ip address
|
||||||
|
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
|
||||||
|
Ttl uint `json:"ttl"`
|
||||||
|
}
|
||||||
|
URL := constants.CloudflareURL + "/zones/" + zoneIdentifier + "/dns_records/" + identifier
|
||||||
|
r, err := network.BuildHTTPPut(
|
||||||
|
URL,
|
||||||
|
cloudflarePutBody{
|
||||||
|
Type: "A",
|
||||||
|
Name: host,
|
||||||
|
Content: ip.String(),
|
||||||
|
Proxied: proxied,
|
||||||
|
Ttl: ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(token) > 0:
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
case len(userServiceKey) > 0:
|
||||||
|
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
|
||||||
|
case len(email) > 0 && len(key) > 0:
|
||||||
|
r.Header.Set("X-Auth-Email", email)
|
||||||
|
r.Header.Set("X-Auth-Key", key)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("email and key are both unset and user service key is not set and no token was provided")
|
||||||
|
}
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status > http.StatusUnsupportedMediaType {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var parsedJSON struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Errors []struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
Result struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !parsedJSON.Success {
|
||||||
|
var errStr string
|
||||||
|
for _, e := range parsedJSON.Errors {
|
||||||
|
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(errStr)
|
||||||
|
}
|
||||||
|
newIP := net.ParseIP(parsedJSON.Result.Content)
|
||||||
|
if newIP == nil {
|
||||||
|
return fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
|
||||||
|
} else if !newIP.Equal(ip) {
|
||||||
|
return fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
99
internal/update/dnspod.go
Normal file
99
internal/update/dnspod.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateDNSPod(client libnetwork.Client, domain, host, token string, ip net.IP) (err error) {
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("IP address was not given to updater")
|
||||||
|
}
|
||||||
|
body := bytes.NewBufferString(url.Values{
|
||||||
|
"login_token": []string{token},
|
||||||
|
"format": []string{"json"},
|
||||||
|
"domain": []string{domain},
|
||||||
|
"length": []string{"200"},
|
||||||
|
"sub_domain": []string{host},
|
||||||
|
"record_type": []string{"A"},
|
||||||
|
}.Encode())
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.List", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
status, content, err := client.DoHTTPRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var recordResp struct {
|
||||||
|
Records []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
} `json:"records"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &recordResp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var recordID, recordLine string
|
||||||
|
for _, record := range recordResp.Records {
|
||||||
|
if record.Type == "A" && record.Name == host {
|
||||||
|
receivedIP := net.ParseIP(record.Value)
|
||||||
|
if ip.Equal(receivedIP) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recordID = record.ID
|
||||||
|
recordLine = record.Line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(recordID) == 0 {
|
||||||
|
return fmt.Errorf("record not found")
|
||||||
|
}
|
||||||
|
body = bytes.NewBufferString(url.Values{
|
||||||
|
"login_token": []string{token},
|
||||||
|
"format": []string{"json"},
|
||||||
|
"domain": []string{domain},
|
||||||
|
"record_id": []string{recordID},
|
||||||
|
"value": []string{ip.String()},
|
||||||
|
"record_line": []string{recordLine},
|
||||||
|
"sub_domain": []string{host},
|
||||||
|
}.Encode())
|
||||||
|
req, err = http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.Ddns", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
status, content, err = client.DoHTTPRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var ddnsResp struct {
|
||||||
|
Record struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"record"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &ddnsResp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
receivedIP := net.ParseIP(ddnsResp.Record.Value)
|
||||||
|
if !ip.Equal(receivedIP) {
|
||||||
|
return fmt.Errorf("ip not set")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
101
internal/update/dreamhost.go
Normal file
101
internal/update/dreamhost.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateDreamhost(client libnetwork.Client, domain, key, domainName string, ip net.IP) error {
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("IP address was not given to updater")
|
||||||
|
}
|
||||||
|
type dreamhostReponse struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
// List records
|
||||||
|
url := strings.ToLower(constants.DreamhostURL + "/?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records")
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var dhList struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
Data []struct {
|
||||||
|
Editable string `json:"editable"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Record string `json:"record"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &dhList); err != nil {
|
||||||
|
return err
|
||||||
|
} else if dhList.Result != "success" {
|
||||||
|
return fmt.Errorf(dhList.Result)
|
||||||
|
}
|
||||||
|
var oldIP net.IP
|
||||||
|
for _, data := range dhList.Data {
|
||||||
|
if data.Type == "A" && data.Record == domainName {
|
||||||
|
if data.Editable == "0" {
|
||||||
|
return fmt.Errorf("record data is not editable")
|
||||||
|
}
|
||||||
|
oldIP = net.ParseIP(data.Value)
|
||||||
|
if ip.Equal(oldIP) { // success, nothing to change
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldIP != nil { // Found editable record with a different IP address, so remove it
|
||||||
|
url = strings.ToLower(constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + domain + "&type=A&value=" + oldIP.String())
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, content, err = client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var dhResponse dreamhostReponse
|
||||||
|
if err := json.Unmarshal(content, &dhResponse); err != nil {
|
||||||
|
return err
|
||||||
|
} else if dhResponse.Result != "success" { // this should not happen
|
||||||
|
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create the right record
|
||||||
|
url = strings.ToLower(constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + domain + "&type=A&value=" + ip.String())
|
||||||
|
r, err = http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, content, err = client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var dhResponse dreamhostReponse
|
||||||
|
err = json.Unmarshal(content, &dhResponse)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if dhResponse.Result != "success" {
|
||||||
|
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
internal/update/duckdns.go
Normal file
52
internal/update/duckdns.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
"github.com/qdm12/golibs/verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateDuckDNS(client libnetwork.Client, domain, token string, ip net.IP) (newIP net.IP, err error) {
|
||||||
|
url := strings.ToLower(constants.DuckdnsURL + "?domains=" + domain + "&token=" + token + "&verbose=true")
|
||||||
|
if ip != nil {
|
||||||
|
url += "&ip=" + ip.String()
|
||||||
|
}
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
s := string(content)
|
||||||
|
switch {
|
||||||
|
case len(s) < 2:
|
||||||
|
return nil, fmt.Errorf("response %q is too short", s)
|
||||||
|
case s[0:2] == "KO":
|
||||||
|
return nil, fmt.Errorf("invalid domain token combination")
|
||||||
|
case s[0:2] == "OK":
|
||||||
|
ips := verification.NewVerifier().SearchIPv4(s)
|
||||||
|
if ips == nil {
|
||||||
|
return nil, fmt.Errorf("no IP address in response")
|
||||||
|
}
|
||||||
|
newIP = net.ParseIP(ips[0])
|
||||||
|
if newIP == nil {
|
||||||
|
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||||
|
}
|
||||||
|
if ip != nil && !newIP.Equal(ip) {
|
||||||
|
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||||
|
}
|
||||||
|
return newIP, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid response %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
internal/update/godaddy.go
Normal file
51
internal/update/godaddy.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/network"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, ip net.IP) error {
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("IP address was not given to updater")
|
||||||
|
}
|
||||||
|
type goDaddyPutBody struct {
|
||||||
|
Data string `json:"data"` // IP address to update to
|
||||||
|
}
|
||||||
|
URL := constants.GodaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
|
||||||
|
r, err := network.BuildHTTPPut(
|
||||||
|
URL,
|
||||||
|
[]goDaddyPutBody{
|
||||||
|
goDaddyPutBody{
|
||||||
|
ip.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
var parsedJSON struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &parsedJSON); err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(parsedJSON.Message) > 0 {
|
||||||
|
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
49
internal/update/namecheap.go
Normal file
49
internal/update/namecheap.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateNamecheap(client libnetwork.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
|
||||||
|
url := strings.ToLower(constants.NamecheapURL + "?host=" + host + "&domain=" + domain + "&password=" + password)
|
||||||
|
if ip != nil {
|
||||||
|
url += "&ip=" + ip.String()
|
||||||
|
}
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if status != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP status %d", status)
|
||||||
|
}
|
||||||
|
var parsedXML struct {
|
||||||
|
Errors struct {
|
||||||
|
Error string `xml:"Err1"`
|
||||||
|
} `xml:"errors"`
|
||||||
|
IP string `xml:"IP"`
|
||||||
|
}
|
||||||
|
err = xml.Unmarshal(content, &parsedXML)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if parsedXML.Errors.Error != "" {
|
||||||
|
return nil, fmt.Errorf(parsedXML.Errors.Error)
|
||||||
|
}
|
||||||
|
newIP = net.ParseIP(parsedXML.IP)
|
||||||
|
if newIP == nil {
|
||||||
|
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
|
||||||
|
}
|
||||||
|
if ip != nil && !ip.Equal(newIP) {
|
||||||
|
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||||
|
}
|
||||||
|
return newIP, nil
|
||||||
|
}
|
||||||
61
internal/update/noip.go
Normal file
61
internal/update/noip.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
|
"github.com/qdm12/golibs/verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateNoIP(client libnetwork.Client, hostname, username, password string, ip net.IP) (newIP net.IP, err error) {
|
||||||
|
url := strings.ToLower(constants.NoIPURL + "?hostname=" + hostname)
|
||||||
|
if ip != nil {
|
||||||
|
url += "&myip=" + ip.String()
|
||||||
|
}
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Header.Set("Authorization", "Basic "+username+":"+password)
|
||||||
|
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
||||||
|
status, content, err := client.DoHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := string(content)
|
||||||
|
switch s {
|
||||||
|
case "":
|
||||||
|
return nil, fmt.Errorf("HTTP status %d", status)
|
||||||
|
case "911":
|
||||||
|
return nil, fmt.Errorf("NoIP's internal server error 911")
|
||||||
|
case "abuse":
|
||||||
|
return nil, fmt.Errorf("username is banned due to abuse")
|
||||||
|
case "!donator":
|
||||||
|
return nil, fmt.Errorf("user has not this extra feature")
|
||||||
|
case "badagent":
|
||||||
|
return nil, fmt.Errorf("user agent is banned")
|
||||||
|
case "badauth":
|
||||||
|
return nil, fmt.Errorf("invalid username password combination")
|
||||||
|
case "nohost":
|
||||||
|
return nil, fmt.Errorf("hostname does not exist")
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
|
||||||
|
ips := verification.NewVerifier().SearchIPv4(s)
|
||||||
|
if ips == nil {
|
||||||
|
return nil, fmt.Errorf("no IP address in response")
|
||||||
|
}
|
||||||
|
newIP = net.ParseIP(ips[0])
|
||||||
|
if newIP == nil {
|
||||||
|
return nil, fmt.Errorf("IP address received %q is malformed", ips[0])
|
||||||
|
}
|
||||||
|
if ip != nil && !ip.Equal(newIP) {
|
||||||
|
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
|
||||||
|
}
|
||||||
|
return newIP, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid response %q", s)
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package update
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/qdm12/ddns-updater/internal/database"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
|
||||||
"github.com/qdm12/golibs/admin"
|
|
||||||
libnetwork "github.com/qdm12/golibs/network"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TriggerServer runs an infinite asynchronous periodic function that triggers updates
|
|
||||||
func TriggerServer(
|
|
||||||
delay time.Duration,
|
|
||||||
chForce, chQuit chan struct{}, // listener only
|
|
||||||
recordsConfigs []models.RecordConfigType, // does not change size so no pointer needed
|
|
||||||
client libnetwork.Client,
|
|
||||||
db database.SQL,
|
|
||||||
gotify admin.Gotify,
|
|
||||||
) {
|
|
||||||
var chQuitArr, chForceArr []chan struct{}
|
|
||||||
defer func() {
|
|
||||||
for i := range chForceArr {
|
|
||||||
close(chForceArr[i])
|
|
||||||
}
|
|
||||||
for i := range chQuitArr {
|
|
||||||
close(chQuitArr[i])
|
|
||||||
}
|
|
||||||
close(chForce)
|
|
||||||
close(chQuit)
|
|
||||||
}()
|
|
||||||
for i := range recordsConfigs {
|
|
||||||
chForceArr = append(chForceArr, make(chan struct{}))
|
|
||||||
chQuitArr = append(chQuitArr, make(chan struct{}))
|
|
||||||
customDelay := recordsConfigs[i].Settings.Delay
|
|
||||||
if customDelay > 0 {
|
|
||||||
go periodicServer(&recordsConfigs[i], customDelay, client, db, chForceArr[i], chQuitArr[i], gotify)
|
|
||||||
} else {
|
|
||||||
go periodicServer(&recordsConfigs[i], delay, client, db, chForceArr[i], chQuitArr[i], gotify)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fan out channel signals
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-chForce:
|
|
||||||
for i := range chForceArr {
|
|
||||||
chForceArr[i] <- struct{}{}
|
|
||||||
}
|
|
||||||
case <-chQuit:
|
|
||||||
for i := range chQuitArr {
|
|
||||||
chQuitArr[i] <- struct{}{}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func periodicServer(
|
|
||||||
recordConfig *models.RecordConfigType,
|
|
||||||
delay time.Duration,
|
|
||||||
client libnetwork.Client,
|
|
||||||
db database.SQL,
|
|
||||||
chForce, chQuit chan struct{},
|
|
||||||
gotify admin.Gotify,
|
|
||||||
) {
|
|
||||||
ticker := time.NewTicker(delay)
|
|
||||||
defer func() {
|
|
||||||
ticker.Stop()
|
|
||||||
close(chForce)
|
|
||||||
close(chQuit)
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
go update(recordConfig, client, db, gotify)
|
|
||||||
case <-chForce:
|
|
||||||
go update(recordConfig, client, db, gotify)
|
|
||||||
case <-chQuit:
|
|
||||||
recordConfig.IsUpdating.Lock() // wait for an eventual update to finish
|
|
||||||
ticker.Stop()
|
|
||||||
close(chForce)
|
|
||||||
close(chQuit)
|
|
||||||
recordConfig.IsUpdating.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,579 +1,171 @@
|
|||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/qdm12/ddns-updater/internal/database"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/models"
|
|
||||||
"github.com/qdm12/ddns-updater/internal/network"
|
|
||||||
"github.com/qdm12/golibs/admin"
|
|
||||||
"github.com/qdm12/golibs/logging"
|
"github.com/qdm12/golibs/logging"
|
||||||
libnetwork "github.com/qdm12/golibs/network"
|
libnetwork "github.com/qdm12/golibs/network"
|
||||||
"github.com/qdm12/golibs/verification"
|
"github.com/qdm12/golibs/verification"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/qdm12/ddns-updater/internal/constants"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/data"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/models"
|
||||||
|
"github.com/qdm12/ddns-updater/internal/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
type Updater interface {
|
||||||
namecheapURL = "https://dynamicdns.park-your-domain.com/update"
|
Update(id int) error
|
||||||
godaddyURL = "https://api.godaddy.com/v1/domains"
|
}
|
||||||
duckdnsURL = "https://www.duckdns.org/update"
|
|
||||||
dreamhostURL = "https://api.dreamhost.com"
|
|
||||||
cloudflareURL = "https://api.cloudflare.com/client/v4"
|
|
||||||
noIPURL = "https://dynupdate.no-ip.com/nic/update"
|
|
||||||
)
|
|
||||||
|
|
||||||
func update(
|
type updater struct {
|
||||||
recordConfig *models.RecordConfigType,
|
db data.Database
|
||||||
client libnetwork.Client,
|
logger logging.Logger
|
||||||
db database.SQL,
|
client libnetwork.Client
|
||||||
gotify admin.Gotify,
|
notify notifyFunc
|
||||||
) {
|
verifier verification.Verifier
|
||||||
var err error
|
}
|
||||||
recordConfig.IsUpdating.Lock()
|
|
||||||
defer recordConfig.IsUpdating.Unlock()
|
|
||||||
recordConfig.Status.SetTime(time.Now())
|
|
||||||
|
|
||||||
// Get the public IP address
|
type notifyFunc func(priority int, messageArgs ...interface{})
|
||||||
ip, err := getPublicIP(client, recordConfig.Settings.IPmethod)
|
|
||||||
if err != nil {
|
func NewUpdater(db data.Database, logger logging.Logger, client libnetwork.Client, notify notifyFunc) Updater {
|
||||||
recordConfig.Status.SetCode(models.FAIL)
|
return &updater{
|
||||||
recordConfig.Status.SetMessage("%s", err)
|
db: db,
|
||||||
logging.Warn(recordConfig.String())
|
logger: logger,
|
||||||
if gotify != nil {
|
client: client,
|
||||||
gotify.Notify("DDNS Updater", 5, recordConfig.String())
|
notify: notify,
|
||||||
}
|
verifier: verification.NewVerifier(),
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Note: empty IP means DNS provider provided
|
}
|
||||||
ips := recordConfig.History.GetIPs()
|
|
||||||
if ip != "" && len(ips) > 0 && ip == ips[0] { // same IP as before
|
func (u *updater) Update(id int) error {
|
||||||
recordConfig.Status.SetCode(models.UPTODATE)
|
record, err := u.db.Select(id)
|
||||||
recordConfig.Status.SetMessage("No IP change for %s", recordConfig.History.GetTSuccessDuration())
|
if err != nil {
|
||||||
return
|
return err
|
||||||
|
}
|
||||||
|
record.Time = time.Now()
|
||||||
|
record.Status = constants.UPDATING
|
||||||
|
if err := u.db.Update(id, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, message, newIP, err := u.update(
|
||||||
|
record.Settings,
|
||||||
|
record.History.GetCurrentIP(),
|
||||||
|
record.History.GetDurationSinceSuccess())
|
||||||
|
record.Status = status
|
||||||
|
record.Message = message
|
||||||
|
if err != nil {
|
||||||
|
if len(record.Message) == 0 {
|
||||||
|
record.Message = err.Error()
|
||||||
|
}
|
||||||
|
if updateErr := u.db.Update(id, record); updateErr != nil {
|
||||||
|
return fmt.Errorf("%s, %s", err, updateErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if newIP != nil {
|
||||||
|
record.History.SuccessTime = time.Now()
|
||||||
|
record.History.IPs = append(record.History.IPs, newIP)
|
||||||
|
u.notify(1, fmt.Sprintf("%s %s", record.Settings.BuildDomainName(), message))
|
||||||
|
}
|
||||||
|
return u.db.Update(id, record) // persists some data if needed (i.e new IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *updater) update(settings models.Settings, currentIP net.IP, durationSinceSuccess string) (status models.Status, message string, newIP net.IP, err error) {
|
||||||
|
// Get the public IP address
|
||||||
|
ip, err := getPublicIP(u.client, settings.IPMethod) // Note: empty IP means DNS provider provided
|
||||||
|
if err != nil {
|
||||||
|
return constants.FAIL, "", nil, err
|
||||||
|
}
|
||||||
|
if ip != nil && ip.Equal(currentIP) {
|
||||||
|
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the record
|
// Update the record
|
||||||
switch recordConfig.Settings.Provider {
|
switch settings.Provider {
|
||||||
case models.PROVIDERNAMECHEAP:
|
case constants.PROVIDERNAMECHEAP:
|
||||||
ip, err = updateNamecheap(
|
ip, err = updateNamecheap(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.Host,
|
settings.Host,
|
||||||
recordConfig.Settings.Domain,
|
settings.Domain,
|
||||||
recordConfig.Settings.Password,
|
settings.Password,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
case models.PROVIDERGODADDY:
|
case constants.PROVIDERGODADDY:
|
||||||
err = updateGoDaddy(
|
err = updateGoDaddy(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.Host,
|
settings.Host,
|
||||||
recordConfig.Settings.Domain,
|
settings.Domain,
|
||||||
recordConfig.Settings.Key,
|
settings.Key,
|
||||||
recordConfig.Settings.Secret,
|
settings.Secret,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
case models.PROVIDERDUCKDNS:
|
case constants.PROVIDERDUCKDNS:
|
||||||
ip, err = updateDuckDNS(
|
ip, err = updateDuckDNS(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.Domain,
|
settings.Domain,
|
||||||
recordConfig.Settings.Token,
|
settings.Token,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
case models.PROVIDERDREAMHOST:
|
case constants.PROVIDERDREAMHOST:
|
||||||
err = updateDreamhost(
|
err = updateDreamhost(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.Domain,
|
settings.Domain,
|
||||||
recordConfig.Settings.Key,
|
settings.Key,
|
||||||
|
settings.BuildDomainName(),
|
||||||
ip,
|
ip,
|
||||||
recordConfig.Settings.BuildDomainName(),
|
|
||||||
)
|
)
|
||||||
case models.PROVIDERCLOUDFLARE:
|
case constants.PROVIDERCLOUDFLARE:
|
||||||
err = updateCloudflare(
|
err = updateCloudflare(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.ZoneIdentifier,
|
settings.ZoneIdentifier,
|
||||||
recordConfig.Settings.Identifier,
|
settings.Identifier,
|
||||||
recordConfig.Settings.Host,
|
settings.Host,
|
||||||
recordConfig.Settings.Email,
|
settings.Email,
|
||||||
recordConfig.Settings.Key,
|
settings.Key,
|
||||||
recordConfig.Settings.UserServiceKey,
|
settings.UserServiceKey,
|
||||||
recordConfig.Settings.Token,
|
settings.Token,
|
||||||
|
settings.Proxied,
|
||||||
|
settings.Ttl,
|
||||||
ip,
|
ip,
|
||||||
recordConfig.Settings.Proxied,
|
|
||||||
recordConfig.Settings.Ttl,
|
|
||||||
)
|
)
|
||||||
case models.PROVIDERNOIP:
|
case constants.PROVIDERNOIP:
|
||||||
ip, err = updateNoIP(
|
ip, err = updateNoIP(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.BuildDomainName(),
|
settings.BuildDomainName(),
|
||||||
recordConfig.Settings.Username,
|
settings.Username,
|
||||||
recordConfig.Settings.Password,
|
settings.Password,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
case models.PROVIDERDNSPOD:
|
case constants.PROVIDERDNSPOD:
|
||||||
err = updateDNSPod(
|
err = updateDNSPod(
|
||||||
client,
|
u.client,
|
||||||
recordConfig.Settings.Domain,
|
settings.Domain,
|
||||||
recordConfig.Settings.Host,
|
settings.Host,
|
||||||
recordConfig.Settings.Token,
|
settings.Token,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unsupported provider \"%s\"", recordConfig.Settings.Provider)
|
err = fmt.Errorf("provider %q is not supported", settings.Provider)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
recordConfig.Status.SetCode(models.FAIL)
|
return constants.FAIL, "", nil, err
|
||||||
recordConfig.Status.SetMessage("%s", err)
|
|
||||||
logging.Warn(recordConfig.String())
|
|
||||||
if gotify != nil {
|
|
||||||
gotify.Notify("DDNS Updater", 5, recordConfig.String())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if len(ips) > 0 && ip == ips[0] { // same IP
|
if ip != nil && ip.Equal(currentIP) {
|
||||||
recordConfig.Status.SetCode(models.UPTODATE)
|
return constants.UPTODATE, fmt.Sprintf("No IP change for %s", durationSinceSuccess), nil, nil
|
||||||
recordConfig.Status.SetMessage("No IP change for %s", recordConfig.History.GetTSuccessDuration())
|
|
||||||
err = db.UpdateIPTime(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip)
|
|
||||||
if err != nil {
|
|
||||||
recordConfig.Status.SetCode(models.FAIL)
|
|
||||||
recordConfig.Status.SetMessage("Cannot update database: %s", err)
|
|
||||||
if gotify != nil {
|
|
||||||
gotify.Notify("DDNS Updater", 4, "Cannot update database: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// new IP
|
|
||||||
recordConfig.Status.SetCode(models.SUCCESS)
|
|
||||||
recordConfig.Status.SetMessage("")
|
|
||||||
recordConfig.History.SetTSuccess(time.Now())
|
|
||||||
recordConfig.History.PrependIP(ip)
|
|
||||||
if gotify != nil {
|
|
||||||
if len(ips) == 0 {
|
|
||||||
gotify.Notify("DDNS Updater", 1, "%s has now IP address %s", recordConfig.Settings.BuildDomainName(), ip)
|
|
||||||
} else {
|
|
||||||
gotify.Notify("DDNS Updater", 1, "%s changed from %s to %s", recordConfig.Settings.BuildDomainName(), ips[0], ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = db.StoreNewIP(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip)
|
|
||||||
if err != nil {
|
|
||||||
recordConfig.Status.SetCode(models.FAIL)
|
|
||||||
recordConfig.Status.SetMessage("Cannot update database: %s", err)
|
|
||||||
if gotify != nil {
|
|
||||||
gotify.Notify("DDNS Updater", 4, "Cannot update database: %s", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return constants.SUCCESS, fmt.Sprintf("changed to %s", ip.String()), ip, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicIP(client libnetwork.Client, IPmethod models.IPMethodType) (ip string, err error) {
|
func getPublicIP(client libnetwork.Client, IPMethod models.IPMethod) (ip net.IP, err error) {
|
||||||
switch IPmethod {
|
switch IPMethod {
|
||||||
case models.IPMETHODPROVIDER:
|
case constants.IPMETHODPROVIDER:
|
||||||
return "", nil
|
return nil, nil
|
||||||
case models.IPMETHODGOOGLE:
|
case constants.IPMETHODGOOGLE:
|
||||||
return network.GetPublicIP(client, "https://google.com/search?q=ip")
|
return network.GetPublicIP(client, "https://google.com/search?q=ip")
|
||||||
case models.IPMETHODOPENDNS:
|
case constants.IPMETHODOPENDNS:
|
||||||
return network.GetPublicIP(client, "https://diagnostic.opendns.com/myip")
|
return network.GetPublicIP(client, "https://diagnostic.opendns.com/myip")
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("IP method %s is not supported", IPmethod)
|
return nil, fmt.Errorf("IP method %q not supported", IPMethod)
|
||||||
}
|
|
||||||
|
|
||||||
func updateNamecheap(client libnetwork.Client, host, domain, password, ip string) (newIP string, err error) {
|
|
||||||
url := strings.ToLower(namecheapURL + "?host=" + host + "&domain=" + domain + "&password=" + password)
|
|
||||||
if len(ip) > 0 {
|
|
||||||
url += "&ip=" + ip
|
|
||||||
}
|
|
||||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if status != 200 { // TODO test / combine with below
|
|
||||||
return "", fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var parsedXML struct {
|
|
||||||
Errors struct {
|
|
||||||
Error string `xml:"Err1"`
|
|
||||||
} `xml:"errors"`
|
|
||||||
IP string `xml:"IP"`
|
|
||||||
}
|
|
||||||
err = xml.Unmarshal(content, &parsedXML)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if parsedXML.Errors.Error != "" {
|
|
||||||
return "", fmt.Errorf(parsedXML.Errors.Error)
|
|
||||||
}
|
|
||||||
ips := verification.SearchIPv4(parsedXML.IP)
|
|
||||||
if ips == nil {
|
|
||||||
return "", fmt.Errorf("no IP address in response")
|
|
||||||
}
|
|
||||||
newIP = ips[0]
|
|
||||||
if len(ip) > 0 && ip != newIP {
|
|
||||||
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
|
|
||||||
}
|
|
||||||
return newIP, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret, ip string) error {
|
|
||||||
if len(ip) == 0 {
|
|
||||||
return fmt.Errorf("invalid empty IP address")
|
|
||||||
}
|
|
||||||
type goDaddyPutBody struct {
|
|
||||||
Data string `json:"data"` // IP address to update to
|
|
||||||
}
|
|
||||||
URL := godaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
|
|
||||||
r, err := network.BuildHTTPPut(
|
|
||||||
URL,
|
|
||||||
[]goDaddyPutBody{
|
|
||||||
goDaddyPutBody{
|
|
||||||
ip,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
var parsedJSON struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(content, &parsedJSON)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if parsedJSON.Message != "" {
|
|
||||||
return fmt.Errorf("HTTP status %d - %s", status, parsedJSON.Message)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateCloudflare(client libnetwork.Client, zoneIdentifier, identifier, host, email, key, userServiceKey, token, ip string, proxied bool, ttl uint) (err error) {
|
|
||||||
if len(ip) == 0 {
|
|
||||||
return fmt.Errorf("invalid empty IP address")
|
|
||||||
}
|
|
||||||
type cloudflarePutBody struct {
|
|
||||||
Type string `json:"type"` // forced to A
|
|
||||||
Name string `json:"name"` // DNS record name i.e. example.com
|
|
||||||
Content string `json:"content"` // ip address
|
|
||||||
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
|
|
||||||
Ttl uint `json:"ttl"`
|
|
||||||
}
|
|
||||||
URL := cloudflareURL + "/zones/" + zoneIdentifier + "/dns_records/" + identifier
|
|
||||||
r, err := network.BuildHTTPPut(
|
|
||||||
URL,
|
|
||||||
cloudflarePutBody{
|
|
||||||
Type: "A",
|
|
||||||
Name: host,
|
|
||||||
Content: ip,
|
|
||||||
Proxied: proxied,
|
|
||||||
Ttl: ttl,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(token) > 0 {
|
|
||||||
r.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
} else if len(userServiceKey) > 0 {
|
|
||||||
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
|
|
||||||
} else if len(email) > 0 && len(key) > 0 {
|
|
||||||
r.Header.Set("X-Auth-Email", email)
|
|
||||||
r.Header.Set("X-Auth-Key", key)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("email and key are both unset and no user service key was provided")
|
|
||||||
}
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status > 415 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var parsedJSON struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Errors []struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"errors"`
|
|
||||||
Result struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(content, &parsedJSON)
|
|
||||||
newIP := parsedJSON.Result.Content
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !parsedJSON.Success {
|
|
||||||
var errStr string
|
|
||||||
for _, e := range parsedJSON.Errors {
|
|
||||||
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
|
|
||||||
}
|
|
||||||
return fmt.Errorf(errStr)
|
|
||||||
} else if newIP != ip {
|
|
||||||
return fmt.Errorf("new IP address %s is not %s", newIP, ip)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDuckDNS(client libnetwork.Client, domain, token, ip string) (newIP string, err error) {
|
|
||||||
url := strings.ToLower(duckdnsURL + "?domains=" + domain + "&token=" + token + "&verbose=true")
|
|
||||||
if len(ip) > 0 {
|
|
||||||
url += "&ip=" + ip
|
|
||||||
}
|
|
||||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
s := string(content)
|
|
||||||
if s[0:2] == "KO" {
|
|
||||||
return "", fmt.Errorf("invalid domain token combination")
|
|
||||||
} else if s[0:2] == "OK" {
|
|
||||||
ips := verification.SearchIPv4(s)
|
|
||||||
if ips == nil {
|
|
||||||
return "", fmt.Errorf("no IP address in response")
|
|
||||||
}
|
|
||||||
newIP = ips[0]
|
|
||||||
if len(ip) > 0 && newIP != ip {
|
|
||||||
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
|
|
||||||
}
|
|
||||||
return newIP, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("response \"%s\"", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDreamhost(client libnetwork.Client, domain, key, ip, domainName string) error {
|
|
||||||
type dreamhostReponse struct {
|
|
||||||
Result string `json:"result"`
|
|
||||||
Data string `json:"data"`
|
|
||||||
}
|
|
||||||
// List records
|
|
||||||
url := strings.ToLower(dreamhostURL + "/?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records")
|
|
||||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var dhList struct {
|
|
||||||
Result string `json:"result"`
|
|
||||||
Data []struct {
|
|
||||||
Editable string `json:"editable"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Record string `json:"record"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(content, &dhList)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if dhList.Result != "success" {
|
|
||||||
return fmt.Errorf(dhList.Result)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
var oldIP string
|
|
||||||
for _, data := range dhList.Data {
|
|
||||||
if data.Type == "A" && data.Record == domainName {
|
|
||||||
if data.Editable == "0" {
|
|
||||||
return fmt.Errorf("record data is not editable")
|
|
||||||
}
|
|
||||||
oldIP = data.Value
|
|
||||||
if oldIP == ip { // success, nothing to change
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found { // Found editable record with a different IP address, so remove it
|
|
||||||
url = strings.ToLower(dreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + domain + "&type=A&value=" + oldIP)
|
|
||||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status, content, err = client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var dhResponse dreamhostReponse
|
|
||||||
err = json.Unmarshal(content, &dhResponse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if dhResponse.Result != "success" { // this should not happen
|
|
||||||
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create the right record
|
|
||||||
url = strings.ToLower(dreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + domain + "&type=A&value=" + ip)
|
|
||||||
r, err = http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status, content, err = client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var dhResponse dreamhostReponse
|
|
||||||
err = json.Unmarshal(content, &dhResponse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if dhResponse.Result != "success" {
|
|
||||||
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNoIP(client libnetwork.Client, hostname, username, password, ip string) (newIP string, err error) {
|
|
||||||
url := strings.ToLower(noIPURL + "?hostname=" + hostname)
|
|
||||||
if len(ip) > 0 {
|
|
||||||
url += "&myip=" + ip
|
|
||||||
}
|
|
||||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
r.Header.Set("Authorization", "Basic "+username+":"+password)
|
|
||||||
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
|
|
||||||
status, content, err := client.DoHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
s := string(content)
|
|
||||||
switch s {
|
|
||||||
case "":
|
|
||||||
return "", fmt.Errorf("HTTP status %d", status)
|
|
||||||
case "911":
|
|
||||||
return "", fmt.Errorf("NoIP's internal server error 911")
|
|
||||||
case "abuse":
|
|
||||||
return "", fmt.Errorf("username is banned due to abuse")
|
|
||||||
case "!donator":
|
|
||||||
return "", fmt.Errorf("user has not this extra feature")
|
|
||||||
case "badagent":
|
|
||||||
return "", fmt.Errorf("user agent is banned")
|
|
||||||
case "badauth":
|
|
||||||
return "", fmt.Errorf("invalid username password combination")
|
|
||||||
case "nohost":
|
|
||||||
return "", fmt.Errorf("hostname does not exist")
|
|
||||||
}
|
|
||||||
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
|
|
||||||
ips := verification.SearchIPv4(s)
|
|
||||||
if ips == nil {
|
|
||||||
return "", fmt.Errorf("no IP address in response")
|
|
||||||
}
|
|
||||||
newIP = ips[0]
|
|
||||||
if len(ip) > 0 && newIP != ip {
|
|
||||||
return "", fmt.Errorf("new IP address %s is not %s", newIP, ip)
|
|
||||||
}
|
|
||||||
return newIP, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("unknown response: %s", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDNSPod(client libnetwork.Client, domain, host, token, ip string) (err error) {
|
|
||||||
postValues := url.Values{}
|
|
||||||
postValues.Set("login_token", token)
|
|
||||||
postValues.Set("format", "json")
|
|
||||||
postValues.Set("domain", domain)
|
|
||||||
postValues.Set("length", "200")
|
|
||||||
postValues.Set("sub_domain", host)
|
|
||||||
postValues.Set("record_type", "A")
|
|
||||||
req, err := http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.List", bytes.NewBufferString(postValues.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
status, content, err := client.DoHTTPRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if status != 200 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var recordResp struct {
|
|
||||||
Records []*struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Line string `json:"line"`
|
|
||||||
} `json:"records"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(content, &recordResp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var recordID, recordLine string
|
|
||||||
for _, record := range recordResp.Records {
|
|
||||||
if record.Type == "A" && record.Name == host {
|
|
||||||
if ip == record.Value {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
recordID = record.ID
|
|
||||||
recordLine = record.Line
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if recordID == "" {
|
|
||||||
return fmt.Errorf("record not found")
|
|
||||||
}
|
|
||||||
postValues = url.Values{}
|
|
||||||
postValues.Set("login_token", token)
|
|
||||||
postValues.Set("format", "json")
|
|
||||||
postValues.Set("domain", domain)
|
|
||||||
postValues.Set("record_id", recordID)
|
|
||||||
postValues.Set("value", ip)
|
|
||||||
postValues.Set("record_line", recordLine)
|
|
||||||
postValues.Set("sub_domain", host)
|
|
||||||
req, err = http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.Ddns", bytes.NewBufferString(postValues.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
status, content, err = client.DoHTTPRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if status != 200 {
|
|
||||||
return fmt.Errorf("HTTP status %d", status)
|
|
||||||
}
|
|
||||||
var ddnsResp struct {
|
|
||||||
Record struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"record"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(content, &ddnsResp); err != nil {
|
|
||||||
return err
|
|
||||||
} else if ddnsResp.Record.Value != ip {
|
|
||||||
return fmt.Errorf("ip not set")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
ui/index.html
132
ui/index.html
@@ -1,81 +1,81 @@
|
|||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>DDNS Updater</title>
|
<title>DDNS Updater</title>
|
||||||
<style>
|
<style>
|
||||||
table {
|
table {
|
||||||
font-family: arial, sans-serif;
|
font-family: arial, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-size: 1vw;
|
font-size: 1vw;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
td, th {
|
td,
|
||||||
border: 2px solid #9a9fa1;
|
th {
|
||||||
text-align: center;
|
border: 2px solid #9a9fa1;
|
||||||
padding: 1%;
|
text-align: center;
|
||||||
max-width: 35%;
|
padding: 1%;
|
||||||
transition: all 0.7s;
|
max-width: 35%;
|
||||||
}
|
transition: all 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: #d8daf7;
|
background-color: #d8daf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(odd) {
|
tr:nth-child(odd) {
|
||||||
background-color: #e6f7ea;
|
background-color: #e6f7ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tr:nth-child(even) {
|
||||||
background-color: #f3ebe3;
|
background-color: #f3ebe3;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
transition: all 0.7s;
|
transition: all 0.7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover {
|
tr:hover {
|
||||||
background: #c1e2f0;
|
background: #c1e2f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Host</th>
|
<th>Host</th>
|
||||||
<th>Provider</th>
|
<th>Provider</th>
|
||||||
<th>IP method</th>
|
<th>IP method</th>
|
||||||
<th>Update status</th>
|
<th>Update status</th>
|
||||||
<th>Set IP</th>
|
<th>Set IP</th>
|
||||||
<th>Previous IPs (achronologically)</th>
|
<th>Previous IPs (achronologically)</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Domain}}</td>
|
<td>{{.Domain}}</td>
|
||||||
<td>{{.Host}}</td>
|
<td>{{.Host}}</td>
|
||||||
<td>{{.Provider}}</td>
|
<td>{{.Provider}}</td>
|
||||||
<td>{{.IPMethod}}</td>
|
<td>{{.IPMethod}}</td>
|
||||||
<td>{{.Status}}</td>
|
<td>{{.Status}}</td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.CurrentIP}}</td>
|
||||||
<td>
|
<td>{{.PreviousIPs}}</td>
|
||||||
{{range .IPs}}
|
</tr>
|
||||||
{{.}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
<div>
|
||||||
{{end}}
|
Made by <a href="https://qqq.ninja">Quentin McGaw</a>
|
||||||
</table>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Made by <a href="https://qqq.ninja">Quentin McGaw</a>
|
<a href="https://github.com/qdm12/ddns-updater">github.com/qdm12/ddns-updater</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<a href="https://github.com/qdm12/ddns-updater">github.com/qdm12/ddns-updater</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user