48 Commits
v2 ... alpine

Author SHA1 Message Date
Quentin McGaw
fe294f52a9 Add healthcheck triggered log line 2020-09-29 22:44:07 +00:00
Quentin McGaw
62e700c82c Alpine image, refers to #100 2020-09-29 22:44:07 +00:00
Quentin McGaw
5d0e8548a1 Healthcheck listens on all interfaces, see #100 2020-09-29 22:44:06 +00:00
Quentin McGaw
5b52255601 He.net, fixes #95 (#96) 2020-09-29 22:43:27 +00:00
Quentin McGaw
04c55028a1 No DNS lookup update detection, fix #104 (#105) 2020-09-27 13:09:02 -04:00
Quentin McGaw
e07e8da31c Rework regexp and fix #101 2020-09-20 21:37:00 +00:00
Quentin McGaw
af2f3a3257 Minor Dockerfile changes 2020-09-18 23:05:55 +00:00
Quentin McGaw
00efca4af4 Remove regex domain check, fix #92 2020-09-05 15:04:52 +00:00
Quentin McGaw
3272612db2 DynDNS Support (#56), fixes #55 2020-08-19 21:50:24 -04:00
Quentin McGaw
5b7968c468 Update dependencies 2020-08-02 15:03:21 +00:00
Quentin McGaw
7ec39c1256 Remove rewrap VScode extension 2020-07-25 15:27:13 -04:00
Quentin McGaw
96857f3bae TZ variable, fix #90 2020-07-25 15:26:23 -04:00
Quentin McGaw
57c7d1be2d Don dominio (#85)
- Fix #85
2020-07-19 18:33:02 -04:00
Shammi Shailaj
53b6f533a8 FIX for issue where cloudflare tokens with a '-' (hyphen/dash) were being deemed invalid (#86)
Thanks!
2020-06-26 15:36:10 -04:00
Quentin McGaw
a82ed93169 Merge branch 'master' of github.com:qdm12/ddns-updater 2020-06-24 14:23:06 +00:00
Quentin McGaw
d07fcc664b Update go extension name for dev container 2020-06-24 14:22:47 +00:00
Quentin McGaw
d013ceb869 Using Alpine 3.12 for building 2020-06-24 14:22:35 +00:00
nu50218
d3506e9792 Fix bug in (*params.reader).GetIPv6Method (#83) 2020-06-17 22:37:19 -04:00
nu50218
c0249672bf Fix incorrect link in README (#82) 2020-06-17 13:40:53 -04:00
Quentin McGaw
cfeb95872a Fix unset status for existing up to date records 2020-06-03 12:26:47 +00:00
Quentin McGaw
091cf5f855 Fix #81 2020-06-03 12:06:16 +00:00
Quentin McGaw
7001add533 Better log messages when IP address changes 2020-06-03 12:02:25 +00:00
Quentin McGaw
9ccdbbd2d3 May fix #80 2020-06-01 21:15:57 +00:00
Quentin McGaw
f8a3ab63c6 Fix #79 2020-06-01 21:01:47 +00:00
Quentin McGaw
14033223d9 Fix healthcheck for ipv4+ipv6 records 2020-05-31 17:58:23 +00:00
Quentin McGaw
18161a6064 Better ip comparison mechanism
- Resolves ip addresses for each fqdn to compare with the IP addresses obtained, instead of comparing with db values
- Using db instead of records, for thread safe operation
- UNSET status at creation of new record
2020-05-31 17:58:09 +00:00
Quentin McGaw
216b8ab1ae String method for settings contains ip version 2020-05-31 15:59:17 +00:00
Quentin McGaw
4af23a756b No logger for Updater 2020-05-31 12:58:05 +00:00
Quentin McGaw
96c84a5a4f Hot fix #76 2020-05-31 12:12:19 +00:00
Quentin McGaw
4564d16c06 Fix version/vcs/build date env variables 2020-05-31 00:50:33 +00:00
Quentin McGaw
6e4a56b3cf Add noip echo service and improve documentation (#74) 2020-05-30 20:38:55 -04:00
Quentin McGaw
919ab65985 IPv6 for all providers but Namecheap (#72)
* DNSPod ipv6 support
* Dreamhost ipv6 support
* GoDaddy ipv6 support
* DuckDNS ipv6 support
* NoIP ipv6 support
* Namecheap does not support ipv6
2020-05-30 20:36:59 -04:00
Quentin McGaw
e023aae909 Cloudflare: get identifier automatically and supports ipv6 (#71) 2020-05-30 20:12:31 -04:00
Quentin McGaw
066bcdd3bf Google domain names support (#70) 2020-05-30 19:46:24 -04:00
Quentin McGaw
0a6c6b9bc7 Fix buildx branch workflow name 2020-05-30 23:44:18 +00:00
Quentin McGaw
8cdff8e4d3 Google ip method, fixes #69 2020-05-30 20:52:22 +00:00
Quentin McGaw
bffc30264f Paths ignore adjustments in docker build workflows 2020-05-30 20:47:44 +00:00
Quentin McGaw
4f141c20a0 Branch building workflow 2020-05-30 20:45:31 +00:00
Quentin McGaw
582ce626c8 Sort code constants DNS providers alphabetically 2020-05-30 20:18:35 +00:00
Quentin McGaw
13b29aeba4 DNS provider names sorted alphabetically 2020-05-30 20:05:56 +00:00
Quentin McGaw
a5afca15d1 Fix #62 CONFIG env variable 2020-05-30 18:41:31 +00:00
Quentin McGaw
25ee692242 Fixes #68: using hosts for duckdns 2020-05-30 18:14:18 +00:00
Quentin McGaw
922146efd3 Removed some Github workflows
- Greetings doesn't work on forked PRs
- Misspell is done with golangci-lint
- Security doesn't run on Scratch based docker images
2020-05-30 17:42:34 +00:00
Quentin McGaw
db9959cf59 Readme improved and sections moved to Wiki 2020-05-30 17:40:28 +00:00
Quentin McGaw
50303aef7b Issue templates 2020-05-30 17:32:00 +00:00
Quentin McGaw
137e372102 Fix #58 2020-05-30 17:10:11 +00:00
Quentin McGaw
f300c59411 Remove debug log lines 2020-05-30 17:03:23 +00:00
Quentin McGaw
c23998bd09 Refactoring (#63)
- Only calls DNS API(s) once the public IP address changes
- Only one ip method per ip version (ipv4, ipv6, ipv4/v6)
- Gets the ip address once every period for all records
- More object oriented coding instead of functional
- Support to update ipv4 and ipv6 records separately, for supported DNS providers
2020-05-29 20:38:01 -04:00
78 changed files with 3898 additions and 2153 deletions

View File

@@ -12,7 +12,7 @@
"workspaceFolder": "/workspace",
"appPort": 8000,
"extensions": [
"ms-vscode.go",
"golang.go",
"IBM.output-colorizer",
"eamodio.gitlens",
"mhutchie.git-graph",
@@ -23,7 +23,6 @@
"mohsen1.prettify-json",
"quicktype.quicktype",
"spikespaz.vscode-smoothtype",
"stkb.rewrap",
"vscode-icons-team.vscode-icons"
],
"settings": {

63
.github/ISSUE_TEMPLATE/bug.md vendored Normal file
View File

@@ -0,0 +1,63 @@
---
name: Bug
about: Report a bug
title: 'Bug: ...'
labels: ":bug: bug"
assignees: qdm12
---
**TLDR**: *Describe your issue in a one liner here*
1. Is this urgent?
- [ ] Yes
- [x] No
2. What DNS service provider(s) are you using?
- [x] Cloudflare
- [ ] DDNSS.de
- [ ] DonDominio
- [ ] DNSPod
- [ ] Dreamhost
- [ ] DuckDNS
- [ ] DynDNS
- [ ] GoDaddy
- [ ] Google
- [ ] He.net
- [ ] Infomaniak
- [ ] Namecheap
- [ ] NoIP
3. What's the version of the program?
**See the line at the top of your logs**
`Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c)`
4. What are you using to run the container?
- [ ] Docker run
- [x] Docker Compose
- [ ] Kubernetes
- [ ] Docker stack
- [ ] Docker swarm
- [ ] Podman
- [ ] Other:
5. Extra information
Logs:
```log
```
Configuration file (**remove your credentials!**):
```json
```
Host OS:

View File

@@ -0,0 +1,14 @@
---
name: Feature request
about: Suggest a feature to add to this project
title: 'Feature request: ...'
labels: ":bulb: feature request"
assignees: qdm12
---
1. What's the feature?
2. Why do you need this feature?
3. Extra information?

63
.github/ISSUE_TEMPLATE/help.md vendored Normal file
View File

@@ -0,0 +1,63 @@
---
name: Help
about: Ask for help
title: 'Help: ...'
labels: ":pray: help wanted"
assignees:
---
**TLDR**: *Describe your issue in a one liner here*
1. Is this urgent?
- [ ] Yes
- [x] No
2. What DNS service provider(s) are you using?
- [x] Cloudflare
- [ ] DDNSS.de
- [ ] DonDominio
- [ ] DNSPod
- [ ] Dreamhost
- [ ] DuckDNS
- [ ] DynDNS
- [ ] GoDaddy
- [ ] Google
- [ ] He.net
- [ ] Infomaniak
- [ ] Namecheap
- [ ] NoIP
3. What's the version of the program?
**See the line at the top of your logs**
`Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c)`
4. What are you using to run the container?
- [ ] Docker run
- [x] Docker Compose
- [ ] Kubernetes
- [ ] Docker stack
- [ ] Docker swarm
- [ ] Podman
- [ ] Other:
5. Extra information
Logs:
```log
```
Configuration file:
```yml
```
Host OS:

View File

@@ -2,6 +2,25 @@ name: Docker build
on:
pull_request:
branches: [master]
paths-ignore:
- .devcontainer
- .github/ISSUE_TEMPLATE
- .github/workflows/build-branch.yml
- .github/workflows/buildx-release.yml
- .github/workflows/buildx-latest.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/labels.yml
- .github/CODEOWNERS
- .github/CONTRIBUTING.md
- .github/FUNDING.yml
- .github/labels.yml
- .vscode
- readme
- .gitignore
- config.json
- docker-compose.yml
- LICENSE
- README.md
jobs:
build:
runs-on: ubuntu-latest

47
.github/workflows/buildx-branch.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Buildx branch
on:
push:
branches:
- '*'
- '*/*'
- '!master'
paths-ignore:
- .devcontainer
- .github/ISSUE_TEMPLATE
- .github/workflows/build.yml
- .github/workflows/buildx-release.yml
- .github/workflows/buildx-latest.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/labels.yml
- .github/CODEOWNERS
- .github/CONTRIBUTING.md
- .github/FUNDING.yml
- .github/labels.yml
- .vscode
- readme
- .gitignore
- config.json
- docker-compose.yml
- LICENSE
- README.md
jobs:
buildx:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Buildx setup
uses: crazy-max/ghaction-docker-buildx@v1
- name: Dockerhub login
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
- name: Run Buildx
run: |
docker buildx build \
--progress plain \
--platform=linux/amd64,linux/386,linux/arm64,linux/arm/v7 \
--build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \
--build-arg VCS_REF=`git rev-parse --short HEAD` \
--build-arg VERSION=${GITHUB_REF##*/} \
-t qmcgaw/ddns-updater:${GITHUB_REF##*/} \
--push \
.
- run: curl -X POST https://hooks.microbadger.com/images/qmcgaw/ddns-updater/t2fcZxog8ce_kJYJ61JjkYwHF5s= || exit 0

View File

@@ -3,18 +3,24 @@ on:
push:
branches: [master]
paths-ignore:
- .devcontainer
- .github/ISSUE_TEMPLATE
- .github/workflows/build.yml
- .github/workflows/buildx-release.yml
- .github/workflows/buildx-branch.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/greetings.yml
- .github/workflows/labels.yml
- .github/workflows/misspell.yml
- .github/workflows/security.yml
- .dockerignore
- .github/CODEOWNERS
- .github/CONTRIBUTING.md
- .github/FUNDING.yml
- .github/labels.yml
- .vscode
- readme
- .gitignore
- config.json
- docker-compose.yml
- LICENSE
- README.md
- title.svg
jobs:
buildx:
runs-on: ubuntu-latest
@@ -22,8 +28,6 @@ jobs:
- uses: actions/checkout@v2
- name: Buildx setup
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Dockerhub login
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
- name: Run Buildx

View File

@@ -3,27 +3,31 @@ on:
release:
types: [published]
paths-ignore:
- .devcontainer
- .github/ISSUE_TEMPLATE
- .github/workflows/build.yml
- .github/workflows/buildx-branch.yml
- .github/workflows/buildx-latest.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/greetings.yml
- .github/workflows/labels.yml
- .github/workflows/misspell.yml
- .github/workflows/security.yml
- .dockerignore
- .github/CODEOWNERS
- .github/CONTRIBUTING.md
- .github/FUNDING.yml
- .github/labels.yml
- .vscode
- readme
- .gitignore
- config.json
- docker-compose.yml
- LICENSE
- README.md
- title.svg
jobs:
buildx:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: buildx
- name: Buildx setup
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Dockerhub login
run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u qmcgaw --password-stdin 2>&1
- name: Run Buildx

View File

@@ -1,11 +0,0 @@
name: Greetings
on: [pull_request, issues]
jobs:
greeting:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: 'Thanks for creating your first issue :+1: Feel free to use [Slack](https://join.slack.com/t/qdm12/shared_invite/enQtODMwMDQyMTAxMjY1LTU1YjE1MTVhNTBmNTViNzJiZmQwZWRmMDhhZjEyNjVhZGM4YmIxOTMxOTYzN2U0N2U2YjQ2MDk3YmYxN2NiNTc) if you just need some quick help or want to chat'
pr-message: 'Thank you so much for contributing, that means a lot to me :wink:'

View File

@@ -1,16 +0,0 @@
name: Misspells
on:
pull_request:
branches: [master]
push:
branches: [master]
jobs:
misspell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: reviewdog/action-misspell@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
locale: "US"
level: error

View File

@@ -1,59 +0,0 @@
name: Security scan of Docker image
on:
push:
branches: [master]
paths-ignore:
- .github/workflows/buildx-release.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/greetings.yml
- .github/workflows/labels.yml
- .github/workflows/misspell.yml
- .github/workflows/security.yml
- .dockerignore
- .gitignore
- docker-compose.yml
- LICENSE
- README.md
- title.svg
pull_request:
branches: [master]
paths-ignore:
- .github/workflows/buildx-release.yml
- .github/workflows/dockerhub-description.yml
- .github/workflows/greetings.yml
- .github/workflows/labels.yml
- .github/workflows/misspell.yml
- .github/workflows/security.yml
- .dockerignore
- .gitignore
- docker-compose.yml
- LICENSE
- README.md
- title.svg
schedule:
- cron: '0 9 * * *'
jobs:
security-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Check for scratch
id: scratchCheck
run: echo ::set-output name=scratch::$(cat Dockerfile | grep 'FROM scratch')
- name: Build image
if: steps.scratchCheck.outputs.scratch == ''
run: docker build -t image .
- name: Phonito
if: steps.scratchCheck.outputs.scratch == ''
uses: phonito/phonito-scanner-action@master
with:
image: image
fail-level: LOW
phonito-token: ${{ secrets.PHONITO_TOKEN }}
- name: Trivy
if: steps.scratchCheck.outputs.scratch == ''
uses: homoluctus/gitrivy@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
image: image

View File

@@ -44,6 +44,3 @@ run:
skip-dirs:
- .devcontainer
- .github
service:
golangci-lint-version: 1.26.x # use the fixed version to not introduce new linters unexpectedly

View File

@@ -1,28 +1,31 @@
ARG ALPINE_VERSION=3.11
ARG GO_VERSION=1.14
ARG ALPINE_VERSION=3.12
ARG GO_VERSION=1.15
FROM alpine:${ALPINE_VERSION} AS alpine
RUN apk --update add ca-certificates tzdata
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
ARG GOLANGCI_LINT_VERSION=v1.26.0
ARG GOLANGCI_LINT_VERSION=v1.31.0
RUN apk --update add git
ENV CGO_ENABLED=0
RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s ${GOLANGCI_LINT_VERSION}
WORKDIR /tmp/gobuild
COPY .golangci.yml .
COPY go.mod go.sum ./
RUN go mod download 2>&1
RUN go mod download
COPY internal/ ./internal/
COPY cmd/updater/main.go .
RUN go test ./...
RUN go build -ldflags="-s -w" -o app
RUN go build -trimpath -ldflags="-s -w" -o app
RUN golangci-lint run --timeout=10m
FROM scratch
FROM alpine:${ALPINE_VERSION}
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
ENV VERSION=$VERSION \
BUILD_DATE=$BUILD_DATE \
VCS_REF=$VCS_REF
LABEL \
org.opencontainers.image.authors="quentin.mcgaw@gmail.com" \
org.opencontainers.image.created=$BUILD_DATE \
@@ -32,23 +35,36 @@ LABEL \
org.opencontainers.image.documentation="https://github.com/qdm12/ddns-updater" \
org.opencontainers.image.source="https://github.com/qdm12/ddns-updater" \
org.opencontainers.image.title="ddns-updater" \
org.opencontainers.image.description="Universal DNS updater with WebUI. Works with Namecheap, Cloudflare, GoDaddy, DuckDns, Dreamhost, DNSPod and NoIP"
org.opencontainers.image.description="Universal DNS updater with WebUI. Works with Cloudflare, DDNSS.de, DNSPod, Dreamhost, DuckDNS, DynDNS, GoDaddy, Google, He.net, Infomaniak, Namecheap and NoIP"
COPY --from=alpine --chown=1000 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=alpine --chown=1000 /usr/share/zoneinfo /usr/share/zoneinfo
EXPOSE 8000
HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=2 CMD ["/updater/app", "healthcheck"]
USER 1000
ENTRYPOINT ["/updater/app"]
ENV DELAY=10m \
ROOT_URL=/ \
ENV \
# Core
CONFIG= \
PERIOD=5m \
IP_METHOD=cycle \
IPV4_METHOD=cycle \
IPV6_METHOD=cycle \
HTTP_TIMEOUT=10s \
# Web UI
LISTENING_PORT=8000 \
ROOT_URL=/ \
# Backup
BACKUP_PERIOD=0 \
BACKUP_DIRECTORY=/updater/data \
# Other
LOG_ENCODING=console \
LOG_LEVEL=info \
NODE_ID=0 \
HTTP_TIMEOUT=10s \
NODE_ID=-1 \
GOTIFY_URL= \
GOTIFY_TOKEN= \
BACKUP_PERIOD=0 \
BACKUP_DIRECTORY=/updater/data
TZ=
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
COPY --chown=1000 ui/* /updater/ui/

385
README.md
View File

@@ -1,6 +1,6 @@
# Lightweight universal DDNS Updater with Docker and web UI
*Light container updating DNS A records periodically for GoDaddy, Namecheap, Cloudflare, Dreamhost, NoIP, DNSPod, Infomaniak, ddnss.de and DuckDNS*
*Light container updating DNS A records periodically for Cloudflare, DDNSS.de, DonDominio, DNSPod, Dreamhost, DuckDNS, DynDNS, GoDaddy, Google, He.net, Infomaniak, Namecheap and NoIP*
[![DDNS Updater by Quentin McGaw](https://github.com/qdm12/ddns-updater/raw/master/readme/title.png)](https://hub.docker.com/r/qmcgaw/ddns-updater)
@@ -17,12 +17,12 @@
## Features
- Updates periodically A records for different DNS providers: Namecheap, GoDaddy, Cloudflare, NoIP, Dreamhost, DuckDNS, DNSPod and Infomaniak (ask for more)
- Updates periodically A records for different DNS providers: Cloudflare, DDNSS.de, DonDominio, DNSPod, Dreamhost, DuckDNS, DynDNS, GoDaddy, Google, He.net, Infomaniak, Namecheap and NoIP ([create an issue](https://github.com/qdm12/ddns-updater/issues/new/choose) for more)
- Web User interface
![Web UI](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/webui.png)
- 12.3MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
- 14MB Docker image based on a Go static binary in a Scratch Docker image with ca-certificates and timezone data
- Persistence with a JSON file *updates.json* to store old IP addresses with change times for each record
- Docker healthcheck verifying the DNS resolution of your domains
- Highly configurable
@@ -31,7 +31,52 @@
## Setup
1. To setup your domains initially, see the [Domain set up](#domain-set-up) section.
The program reads the configuration from a JSON configuration file.
1. First, create a JSON configuration starting from, for example:
```json
{
"settings": [
{
"provider": "namecheap",
"domain": "example.com",
"host": "@",
"password": "e5322165c1d74692bfa6d807100c0310"
},
{
"provider": "duckdns",
"domain": "example.duckdns.org",
"token": "00000000-0000-0000-0000-000000000000"
},
{
"provider": "godaddy",
"domain": "example.org",
"host": "subdomain",
"key": "aaaaaaaaaaaaaaaa",
"secret": "aaaaaaaaaaaaaaaa"
}
]
}
```
1. You can find more information in the [configuration section](#configuration) to customize it.
1. You can either use a bind mounted file or put all your JSON in a single line with the `CONFIG` environment variable, see the two subsections below for each
### Using the CONFIG variable
1. Remove all 'new lines' in order to put your entire JSON in a single line (i.e. `{"settings": [{"provider": "namecheap", ...}]}`)
1. Set the `CONFIG` environment variable to your single line configuration
1. Use the following command:
```sh
docker run -d -p 8000:8000/tcp -e CONFIG='{"settings": [{"provider": "namecheap", ...}]}' qmcgaw/ddns-updater
```
Note that this CONFIG environment variable takes precedence over the config.json file if it is set.
### Using a file
1. Create a directory of your choice, say *data* with a file named **config.json** inside:
```sh
@@ -47,109 +92,60 @@
*(You could change the user ID, for example with `1001`, by running the container with `--user=1001`)*
1. Modify the *data/config.json* file similarly to:
```json
{
"settings": [
{
"provider": "namecheap",
"domain": "example.com",
"host": "@",
"ip_method": "provider",
"delay": 86400,
"password": "e5322165c1d74692bfa6d807100c0310"
},
{
"provider": "duckdns",
"domain": "example.duckdns.org",
"ip_method": "provider",
"token": "00000000-0000-0000-0000-000000000000"
},
{
"provider": "godaddy",
"domain": "example.org",
"host": "subdomain",
"ip_method": "duckduckgo",
"key": "aaaaaaaaaaaaaaaa",
"secret": "aaaaaaaaaaaaaaaa"
}
]
}
```
See more information in the [configuration section](#configuration)
1. Place your JSON configuration in `data/config.json`
1. Use the following command:
```bash
```sh
docker run -d -p 8000:8000/tcp -v "$(pwd)"/data:/updater/data qmcgaw/ddns-updater
```
You can also use [docker-compose.yml](https://github.com/qdm12/ddns-updater/blob/master/docker-compose.yml) with:
### Next steps
```sh
docker-compose up -d
```
You can also use [docker-compose.yml](https://github.com/qdm12/ddns-updater/blob/master/docker-compose.yml) with:
1. You can update the image with `docker pull qmcgaw/ddns-updater`. Other [Docker image tags are available](https://hub.docker.com/repository/docker/qmcgaw/ddns-updater/tags).
```sh
docker-compose up -d
```
You can update the image with `docker pull qmcgaw/ddns-updater`. Other [Docker image tags are available](https://hub.docker.com/repository/docker/qmcgaw/ddns-updater/tags).
## Configuration
Start by having the following content in *config.json*:
Start by having the following content in *config.json*, or in your `CONFIG` environment variable:
```json
{
"settings": [
{
"provider": "",
"domain": "",
"ip_method": "",
},
{
"provider": "",
"domain": "",
"ip_method": "",
}
]
}
```
The following parameters are to be added in *config.json*
For all record update configuration, you need the following:
- `"provider"` is the DNS provider and can be `"godaddy"`, `"namecheap"`, `"duckdns"`, `"dreamhost"`, `"cloudflare"`, `"noip"`, `"dnspod"` or `"ddnss"`
- `"domain"`
- `"ip_method"` is the method to obtain your public IP address and can be:
- `"provider"` means the public IP is automatically determined by the DNS provider (**only for DuckDNs, Namecheap, Infomaniak and NoIP**), most reliable.
- `"opendns"` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip) (reliable)
- `"ifconfig"` using [https://ifconfig.io/ip](https://ifconfig.io/ip) (may be rate limited)
- `"ipinfo"` using [https://ipinfo.io/ip](https://ipinfo.io/ip) (may be rate limited)
- `"ipify"` using [https://api.ipify.org](https://api.ipify.org) (may be rate limited)
- `"ipify6"` using [https://api6.ipify.org](https://api.ipify.org) for IPv6 only (may be rate limited)
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php) for IPv4 only
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php) for IPv6 only
- `"cycle"` to cycle between each external methods, in order to avoid being rate limited
- You can also specify an HTTPS URL to obtain your public IP address (i.e. `"ip_method": "https://ipinfo.io/ip"`)
The following parameters are to be added:
For all record update configuration, you have to specify the DNS provider with `"provider"` which can be `"cloudflare"`, `"ddnss"`, `"dondominio"`, `"dnspod"`, `"dreamhost"`, `"duckdns"`, `"dyn"`, `"godaddy"`, `"google"`, `"he"`, `"infomaniak"`, `"namecheap"` or `"noip"`.
You can optionnally add the parameters:
- `"delay"` is the delay in seconds between each update. It defaults to the `DELAY` environment variable value.
- `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the periodic Docker healthcheck from running a DNS lookup on your domain.
- `"no_dns_lookup"` can be `true` or `false` and allows, if `true`, to prevent the program from doing assumptions from DNS lookups returning an IP address not matching your public IP address (in example for proxied records on Cloudflare).
- `"provider_ip"` can be `true` or `false`. It is only available for the providers `ddnss`, `duckdns`, `he`, `infomaniak`, `namecheap`, `noip` and `dyndns`. It allows to let your DNS provider to determine your IPv4 address (and/or IPv6 address) automatically when you send an update request, without sending the new IP address detected by the program in the request.
For each DNS provider exist some specific parameters you need to add, as described below:
Namecheap:
- `"domain"`
- `"host"` is your host and can be a subdomain, `"@"` or `"*"` generally
- `"password"`
Cloudflare:
- `"zone_identifier"` is the Zone ID of your site
- `"identifier"` is the DNS record identifier as returned by the Cloudflare "List DNS Records" API (see below)
- `"domain"`
- `"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:
@@ -157,61 +153,138 @@ Cloudflare:
- 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
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
GoDaddy:
- `"domain"`
- `"host"` is your host and can be a subdomain, `"@"` or `"*"` generally
- `"key"`
- `"secret"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DuckDNS:
- `"domain"` is your fqdn, for example `subdomain.duckdns.org`
- `"token"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
Dreamhost:
- `"domain"`
- `"key"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
NoIP:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"username"`
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DNSPOD:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"token"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
HE.net:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"` or `"*"` (untested)
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
Infomaniak:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"user"`
- `"password"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DDNSS.de:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"user"`
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DYNDNS:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records)
- `"username"`
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
Google:
- `"domain"`
- `"host"` is your host and can be a subdomain or `"@"` or `"*"`
- `"username"`
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
DonDominio:
- `"domain"`
- `"username"`
- `"password"`
- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
- `"name"` is the name server associated with the domain
### Additional notes
- You can specify multiple hosts for the same domain using a comma separated list. For example with `"host": "@,subdomain1,subdomain2",`.
### Environment variables
| Environment variable | Default | Description |
| --- | --- | --- |
| `DELAY` | `10m` | Default delay between updates, following [this format](https://golang.org/pkg/time/#ParseDuration) |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
| `CONFIG` | | One line JSON object containing the entire config (takes precendence over config.json file) if specified |
| `PERIOD` | `5m` | Default period of IP address check, following [this format](https://golang.org/pkg/time/#ParseDuration) |
| `IP_METHOD` | `cycle` | Method to obtain the public IP address (ipv4 or ipv6). See the [IP Methods section](#IP-methods) |
| `IPV4_METHOD` | `cycle` | Method to obtain the public IPv4 address only. See the [IP Methods section](#IP-methods) |
| `IPV6_METHOD` | `cycle` | Method to obtain the public IPv6 address only. See the [IP Methods section](#IP-methods) |
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
| `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
| `NODE_ID` | `-1` | Node ID (for distributed systems), can be any integer |
| `GOTIFY_URL` | | (optional) HTTP(s) URL to your Gotify server |
| `GOTIFY_TOKEN` | | (optional) Token to access your Gotify server |
| `TZ` | | Timezone to have accurate times, i.e. `America/Montreal` |
#### IP methods
By default, all ip methods are cycled through between all ip methods available for the specified ip version, if any. This allows you not to be blocked for making too many requests. You can otherwise pick one of the following.
- IPv4 or IPv6 (for most cases)
- `opendns` using [https://diagnostic.opendns.com/myip](https://diagnostic.opendns.com/myip)
- `ifconfig` using [https://ifconfig.io/ip](https://ifconfig.io/ip)
- `ipinfo` using [https://ipinfo.io/ip](https://ipinfo.io/ip)
- `ipify` using [https://api.ipify.org](https://api.ipify.org)
- `"ddnss"` using [https://ddnss.de/meineip.php](https://ddnss.de/meineip.php)
- `"google"` using [https://domains.google.com/checkip](https://domains.google.com/checkip)
- IPv4 only (useful for updating both ipv4 and ipv6)
- `ipify` using [https://api.ipify.org](https://api.ipify.org)
- `"ddnss4"` using [https://ip4.ddnss.de/meineip.php](https://ip4.ddnss.de/meineip.php)
- `"noip4"` using [http://ip1.dynupdate.no-ip.com](http://ip1.dynupdate.no-ip.com)
- `"noip8245_4"` using [http://ip1.dynupdate.no-ip.com:8245](http://ip1.dynupdate.no-ip.com:8245)
- IPv6 only
- `ipify6` using [https://api6.ipify.org](https://api6.ipify.org)
- `"ddnss6"` using [https://ip6.ddnss.de/meineip.php](https://ip6.ddnss.de/meineip.php)
- `"noip6"` using [http://ip1.dynupdate.no-ip.com](http://ip1.dynupdate.no-ip.com)
- `"noip8245_6"` using [http://ip1.dynupdate.no-ip.com:8245](http://ip1.dynupdate.no-ip.com:8245)
You can also specify an HTTPS URL to obtain your public IP address (i.e. `-e IPV6_METHOD=https://ipinfo.io/ip`)
### Host firewall
@@ -224,81 +297,7 @@ If you have a host firewall in place, this container needs the following ports:
## Domain set up
### Namecheap
[![Namecheap Website](https://github.com/qdm12/ddns-updater/raw/master/readme/namecheap.png)](https://www.namecheap.com)
1. Create a Namecheap account and buy a domain name - *example.com* as an example
1. Login to Namecheap at [https://www.namecheap.com/myaccount/login.aspx](https://www.namecheap.com/myaccount/login.aspx)
For **each domain name** you want to add, replace *example.com* in the following link with your domain name and go to [https://ap.www.namecheap.com/Domains/DomainControlPanel/**example.com**/advancedns](https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns)
1. For each host you want to add (if you don't know, create one record with the host set to `*`):
1. In the *HOST RECORDS* section, click on *ADD NEW RECORD*
![https://ap.www.namecheap.com/Domains/DomainControlPanel/mealracle.com/advancedns](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/namecheap1.png)
1. Select the following settings and create the *A + Dynamic DNS Record*:
![https://ap.www.namecheap.com/Domains/DomainControlPanel/mealracle.com/advancedns](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/namecheap2.png)
1. Scroll down and turn on the switch for *DYNAMIC DNS*
![https://ap.www.namecheap.com/Domains/DomainControlPanel/mealracle.com/advancedns](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/namecheap3.png)
1. The Dynamic DNS Password will appear, which is `0e4512a9c45a4fe88313bcc2234bf547` in this example.
![https://ap.www.namecheap.com/Domains/DomainControlPanel/mealracle.com/advancedns](https://raw.githubusercontent.com/qdm12/ddns-updater/master/readme/namecheap4.png)
***
### GoDaddy
[![GoDaddy Website](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddy.png)](https://godaddy.com)
1. Login to [https://developer.godaddy.com/keys](https://developer.godaddy.com/keys/) with your account credentials.
[![GoDaddy Developer Login](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddy1.gif)](https://developer.godaddy.com/keys)
1. Generate a Test key and secret.
[![GoDaddy Developer Test Key](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddy2.gif)](https://developer.godaddy.com/keys)
1. Generate a **Production** key and secret.
[![GoDaddy Developer Production Key](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddy3.gif)](https://developer.godaddy.com/keys)
Obtain the **key** and **secret** of that production key.
In this example, the key is `dLP4WKz5PdkS_GuUDNigHcLQFpw4CWNwAQ5` and the secret is `GuUFdVFj8nJ1M79RtdwmkZ`.
***
### DuckDNS
[![DuckDNS Website](https://github.com/qdm12/ddns-updater/raw/master/readme/duckdns.png)](https://duckdns.org)
*See [duckdns website](https://duckdns.org)*
### Cloudflare
1. Make sure you have `curl` installed
1. Obtain your API key from Cloudflare website ([see this](https://support.cloudflare.com/hc/en-us/articles/200167836-Where-do-I-find-my-Cloudflare-API-key-))
1. Obtain your zone identifier for your domain name, from the domain's overview page written as *Zone ID*
1. Find your **identifier** in the `id` field with
```sh
ZONEID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
EMAIL=example@example.com
APIKEY=aaaaaaaaaaaaaaaaaa
curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONEID/dns_records" \
-H "X-Auth-Email: $EMAIL" \
-H "X-Auth-Key: $APIKEY"
```
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.
Instructions to setup your domain for this program are available for DuckDNS, Cloudflare, GoDaddy and Namecheap on the [Github Wiki](https://github.com/qdm12/ddns-updater/wiki).
## Gotify
@@ -321,70 +320,34 @@ To set it up with DDNS updater:
## Testing
- The automated healthcheck verifies all your records are up to date [using DNS lookups](https://github.com/qdm12/ddns-updater/blob/master/internal/healthcheck/healthcheck.go#L15)
- You can check manually at:
- GoDaddy: [https://dcc.godaddy.com/manage/yourdomain.com/dns](https://dcc.godaddy.com/manage/yourdomain.com/dns) (replace yourdomain.com)
- You can also manually check, by:
1. Going to your DNS management webpage
1. Setting your record to `127.0.0.1`
1. Run the container
1. Refresh the DNS management webpage and verify the update happened
[![GoDaddy DNS management](https://github.com/qdm12/ddns-updater/raw/master/readme/godaddydnsmanagement.png)](https://dcc.godaddy.com/manage/)
Better testing instructions are written in the [Wiki for GoDaddy](https://github.com/qdm12/ddns-updater/wiki/GoDaddy#testing)
You might want to try to change the IP address to `127.0.0.1` to see if the update actually occurs.
## Development and contributing
## Development
- Contribute with code: see [the Wiki](https://github.com/qdm12/ddns-updater/wiki/Contributing)
- [Github workflows to know what's building](https://github.com/qdm12/ddns-updater/actions)
- [List of issues and feature requests](https://github.com/qdm12/ddns-updater/issues)
- [Kanban board](https://github.com/qdm12/ddns-updater/projects/1)
1. Setup your environment
## License
<details><summary>Using VSCode and Docker (easier)</summary><p>
1. Install [Docker](https://docs.docker.com/install/)
- On Windows, share a drive with Docker Desktop and have the project on that partition
- On OSX, share your project directory with Docker Desktop
1. With [Visual Studio Code](https://code.visualstudio.com/download), install the [remote containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
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: So you can discard it and update it easily!
</p></details>
<details><summary>Locally</summary><p>
1. Install [Go](https://golang.org/dl/), [Docker](https://www.docker.com/products/docker-desktop) and [Git](https://git-scm.com/downloads)
1. Install Go dependencies with
```sh
go mod download
```
1. Install [golangci-lint](https://github.com/golangci/golangci-lint#install)
1. You might want to use an editor such as [Visual Studio Code](https://code.visualstudio.com/download) with the [Go extension](https://code.visualstudio.com/docs/languages/go). Working settings are already in [.vscode/settings.json](https://github.com/qdm12/ddns-updater/master/.vscode/settings.json).
</p></details>
1. Commands available:
```sh
# Build the binary
go build cmd/app/main.go
# Test the code
go test ./...
# Lint the code
golangci-lint run
# Build the Docker image
docker build -t qmcgaw/ddns-updater .
```
1. See [Contributing](https://github.com/qdm12/ddns-updater/master/.github/CONTRIBUTING.md) for more information on how to contribute to this repository.
This repository is under an [MIT license](https://github.com/qdm12/ddns-updater/master/license)
## Used in external projects
- [Starttoaster/docker-traefik](https://github.com/Starttoaster/docker-traefik#home-networks-extra-credit-dynamic-dns)
## TODOs
## Support
- [ ] Update dependencies
- [ ] Mockgen instead of mockery
- [ ] Other types or records
- [ ] icon.ico for webpage
- [ ] Record events log
- [ ] Hot reload of config.json
- [ ] Unit tests
- [ ] ReactJS frontend
- [ ] Live update of website
- [ ] Change settings
Sponsor me on [Github](https://github.com/sponsors/qdm12) or donate to [paypal.me/qmcgaw](https://www.paypal.me/qmcgaw)
[![https://github.com/sponsors/qdm12](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/sponsors.jpg)](https://github.com/sponsors/qdm12)
[![https://www.paypal.me/qmcgaw](https://raw.githubusercontent.com/qdm12/private-internet-access-docker/master/doc/paypal.jpg)](https://www.paypal.me/qmcgaw)
Many thanks to J. Famiglietti for supporting me financially 🥇👍

View File

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

View File

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

View File

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

10
go.mod
View File

@@ -1,11 +1,11 @@
module github.com/qdm12/ddns-updater
go 1.13
go 1.15
require (
github.com/golang/mock v1.4.3
github.com/golang/mock v1.4.4
github.com/google/uuid v1.1.1
github.com/kyokomi/emoji v2.2.2+incompatible
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151
github.com/stretchr/testify v1.5.1
github.com/kyokomi/emoji v2.2.4+incompatible
github.com/qdm12/golibs v0.0.0-20200712151944-a0325873bf5a
github.com/stretchr/testify v1.6.1
)

18
go.sum
View File

@@ -39,6 +39,8 @@ 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/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
@@ -53,10 +55,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o=
github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/kyokomi/emoji v2.2.2+incompatible h1:gaQFbK2+uSxOR4iGZprJAbpmtqTrHhSdgOyIMD6Oidc=
github.com/kyokomi/emoji v2.2.2+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+3evou4BEPv4=
github.com/kyokomi/emoji v2.2.4+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
@@ -76,8 +76,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151 h1:5q8oyhJqgQyW5v427CDC34SobllqiJCLLfS3Z4EeLCI=
github.com/qdm12/golibs v0.0.0-20200430173218-57de728e2151/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
github.com/qdm12/golibs v0.0.0-20200712151944-a0325873bf5a h1:IyS72qFm+iXipadmUKXmpJScKXXK2GrD8yYfxXsnIYs=
github.com/qdm12/golibs v0.0.0-20200712151944-a0325873bf5a/go.mod h1:pikkTN7g7zRuuAnERwqW1yAFq6pYmxrxpjiwGvb0Ysc=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -86,8 +86,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc=
@@ -133,6 +133,8 @@ 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.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,27 +4,35 @@ import "github.com/qdm12/ddns-updater/internal/models"
// All possible provider values
const (
GODADDY models.Provider = "godaddy"
NAMECHEAP models.Provider = "namecheap"
DUCKDNS models.Provider = "duckdns"
DREAMHOST models.Provider = "dreamhost"
CLOUDFLARE models.Provider = "cloudflare"
NOIP models.Provider = "noip"
DNSPOD models.Provider = "dnspod"
INFOMANIAK models.Provider = "infomaniak"
DDNSSDE models.Provider = "ddnss"
DONDOMINIO models.Provider = "dondominio"
DNSPOD models.Provider = "dnspod"
DUCKDNS models.Provider = "duckdns"
DYN models.Provider = "dyn"
DREAMHOST models.Provider = "dreamhost"
GODADDY models.Provider = "godaddy"
GOOGLE models.Provider = "google"
HE models.Provider = "he"
INFOMANIAK models.Provider = "infomaniak"
NAMECHEAP models.Provider = "namecheap"
NOIP models.Provider = "noip"
)
func ProviderChoices() []models.Provider {
return []models.Provider{
GODADDY,
NAMECHEAP,
DUCKDNS,
DREAMHOST,
CLOUDFLARE,
NOIP,
DNSPOD,
INFOMANIAK,
DDNSSDE,
DONDOMINIO,
DNSPOD,
DUCKDNS,
DYN,
DREAMHOST,
GODADDY,
GOOGLE,
HE,
INFOMANIAK,
NAMECHEAP,
NOIP,
}
}

View File

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

View File

@@ -2,9 +2,9 @@ package constants
const (
// Announcement is a message announcement
Announcement = "Smaller Docker image based on Scratch (12.3MB)"
Announcement = "Support for he.net"
// AnnouncementExpiration is the expiration date of the announcement in format yyyy-mm-dd
AnnouncementExpiration = "2020-04-20"
AnnouncementExpiration = "2020-10-15"
)
const (

View File

@@ -7,4 +7,5 @@ const (
SUCCESS models.Status = "success"
UPTODATE models.Status = "up to date"
UPDATING models.Status = "updating"
UNSET models.Status = "unset"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package healthcheck
import (
"fmt"
"net"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/data"
@@ -13,6 +14,7 @@ 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) {
logger.Info("Healthcheck triggered!")
defer func() {
if err != nil {
logger.Warn("unhealthy: %s", err)
@@ -22,24 +24,30 @@ func IsHealthy(db data.Database, lookupIP lookupIPFunc, logger logging.Logger) (
for _, record := range records {
if record.Status == constants.FAIL {
return fmt.Errorf("%s", record.String())
} else if record.Settings.NoDNSLookup {
} else if !record.Settings.DNSLookup() {
continue
}
lookedUpIPs, err := lookupIP(record.Settings.BuildDomainName())
hostname := record.Settings.BuildDomainName()
lookedUpIPs, err := lookupIP(hostname)
if err != nil {
return err
}
currentIP := record.History.GetCurrentIP()
if currentIP == nil {
return fmt.Errorf("no set IP address found")
return fmt.Errorf("no database set IP address found for %s", hostname)
}
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)
found := false
lookedUpIPsString := make([]string, len(lookedUpIPs))
for i, lookedUpIP := range lookedUpIPs {
lookedUpIPsString[i] = lookedUpIP.String()
if lookedUpIP.Equal(currentIP) {
found = true
break
}
}
if !found {
return fmt.Errorf("lookup IP addresses for %s are %s instead of %s", hostname, strings.Join(lookedUpIPsString, ","), currentIP)
}
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,89 +3,149 @@ package params
import (
"encoding/json"
"fmt"
"time"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
"github.com/qdm12/ddns-updater/internal/settings"
)
// nolint: maligned
type settingsType struct {
Provider string `json:"provider"`
Domain string `json:"domain"`
IPMethod string `json:"ip_method"`
IPVersion string `json:"ip_version"`
Delay uint64 `json:"delay"`
NoDNSLookup bool `json:"no_dns_lookup"`
Host string `json:"host"`
Password string `json:"password"` // Namecheap, NoIP only
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
Secret string `json:"secret"` // GoDaddy only
Token string `json:"token"` // DuckDNS and Cloudflare only
Email string `json:"email"` // Cloudflare only
Username string `json:"username"` // NoIP only
UserServiceKey string `json:"user_service_key"` // Cloudflare only
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
Identifier string `json:"identifier"` // Cloudflare only
Proxied bool `json:"proxied"` // Cloudflare only
TTL uint `json:"ttl"` // Cloudflare only
type commonSettings struct {
Provider string `json:"provider"`
Domain string `json:"domain"`
Host string `json:"host"`
IPVersion string `json:"ip_version"`
NoDNSLookup bool `json:"no_dns_lookup"`
// Retro values for warnings
IPMethod *string `json:"ip_method,omitempty"`
Delay *uint64 `json:"delay,omitempty"`
}
// GetSettings obtain the update settings from config.json
func (r *reader) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
// GetSettings obtain the update settings from the JSON content, first trying from the environment variable CONFIG
// and then from the file config.json
func (r *reader) GetSettings(filePath string) (allSettings []settings.Settings, warnings []string, err error) {
allSettings, warnings, err = r.getSettingsFromEnv()
if allSettings != nil || warnings != nil || err != nil {
return allSettings, warnings, err
}
return r.getSettingsFromFile(filePath)
}
// getSettingsFromFile obtain the update settings from config.json
func (r *reader) getSettingsFromFile(filePath string) (allSettings []settings.Settings, warnings []string, err error) {
bytes, err := r.readFile(filePath)
if err != nil {
return nil, nil, err
}
var config struct {
Settings []settingsType `json:"settings"`
return extractAllSettings(bytes)
}
// getSettingsFromEnv obtain the update settings from the environment variable CONFIG
func (r *reader) getSettingsFromEnv() (allSettings []settings.Settings, warnings []string, err error) {
s, err := r.envParams.GetEnv("CONFIG")
if err != nil {
return nil, nil, err
} else if len(s) == 0 {
return nil, nil, nil
}
if err := json.Unmarshal(bytes, &config); err != nil {
return extractAllSettings([]byte(s))
}
func extractAllSettings(jsonBytes []byte) (allSettings []settings.Settings, warnings []string, err error) {
config := struct {
CommonSettings []commonSettings `json:"settings"`
}{}
rawConfig := struct {
Settings []json.RawMessage `json:"settings"`
}{}
if err := json.Unmarshal(jsonBytes, &config); err != nil {
return nil, nil, err
}
for _, s := range config.Settings {
switch models.Provider(s.Provider) {
case constants.DREAMHOST, constants.DUCKDNS:
s.Host = "@" // only choice available
}
ipMethod := models.IPMethod(s.IPMethod)
// Retro compatibility
if ipMethod == constants.GOOGLE {
r.logger.Warn("IP Method %q is no longer valid, please change it. Defaulting it to %s", constants.GOOGLE, constants.CYCLE)
ipMethod = constants.CYCLE
}
ipVersion := models.IPVersion(s.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4 // default
}
setting := models.Settings{
Provider: models.Provider(s.Provider),
Domain: s.Domain,
Host: s.Host,
IPMethod: ipMethod,
IPVersion: ipVersion,
Delay: time.Second * time.Duration(s.Delay),
NoDNSLookup: s.NoDNSLookup,
Password: s.Password,
Key: s.Key,
Secret: s.Secret,
Token: s.Token,
Email: s.Email,
Username: s.Username,
UserServiceKey: s.UserServiceKey,
ZoneIdentifier: s.ZoneIdentifier,
Identifier: s.Identifier,
Proxied: s.Proxied,
TTL: s.TTL,
}
if err := r.isConsistent(setting); err != nil {
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue
}
settings = append(settings, setting)
if err := json.Unmarshal(jsonBytes, &rawConfig); err != nil {
return nil, nil, err
}
if len(settings) == 0 {
return nil, warnings, fmt.Errorf("no settings found in config.json")
matcher, err := regex.NewMatcher()
if err != nil {
return nil, nil, err
}
return settings, warnings, nil
for i, common := range config.CommonSettings {
newSettings, newWarnings, err := makeSettingsFromObject(common, rawConfig.Settings[i], matcher)
warnings = append(warnings, newWarnings...)
if err != nil {
return nil, warnings, err
}
allSettings = append(allSettings, newSettings...)
}
if len(allSettings) == 0 {
warnings = append(warnings, "no settings found in JSON data")
}
return allSettings, warnings, nil
}
func makeSettingsFromObject(common commonSettings, rawSettings json.RawMessage, matcher regex.Matcher) (settingsSlice []settings.Settings, warnings []string, err error) {
provider := models.Provider(common.Provider)
if provider == constants.DUCKDNS { // only hosts, no domain
if len(common.Domain) > 0 { // retro compatibility
if len(common.Host) == 0 {
common.Host = strings.TrimSuffix(common.Domain, ".duckdns.org")
warnings = append(warnings, fmt.Sprintf("DuckDNS record should have %q specified as host instead of %q as domain", common.Host, common.Domain))
} else {
warnings = append(warnings, fmt.Sprintf("ignoring domain %q because host %q is specified for DuckDNS record", common.Domain, common.Host))
}
}
}
hosts := strings.Split(common.Host, ",")
for _, host := range hosts {
if len(host) == 0 {
return nil, warnings, fmt.Errorf("host cannot be empty")
}
}
ipVersion := models.IPVersion(common.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4OrIPv6 // default
}
if ipVersion != constants.IPv4OrIPv6 && ipVersion != constants.IPv4 && ipVersion != constants.IPv6 {
return nil, warnings, fmt.Errorf("ip version %q is not valid", ipVersion)
}
var settingsConstructor settings.Constructor
switch provider {
case constants.CLOUDFLARE:
settingsConstructor = settings.NewCloudflare
case constants.DDNSSDE:
settingsConstructor = settings.NewDdnss
case constants.DONDOMINIO:
settingsConstructor = settings.NewDonDominio
case constants.DNSPOD:
settingsConstructor = settings.NewDNSPod
case constants.DREAMHOST:
settingsConstructor = settings.NewDreamhost
case constants.DUCKDNS:
settingsConstructor = settings.NewDuckdns
case constants.GODADDY:
settingsConstructor = settings.NewGodaddy
case constants.GOOGLE:
settingsConstructor = settings.NewGoogle
case constants.HE:
settingsConstructor = settings.NewHe
case constants.INFOMANIAK:
settingsConstructor = settings.NewInfomaniak
case constants.NAMECHEAP:
settingsConstructor = settings.NewNamecheap
case constants.NOIP:
settingsConstructor = settings.NewNoip
case constants.DYN:
settingsConstructor = settings.NewDyn
default:
return nil, warnings, fmt.Errorf("provider %q is not supported", provider)
}
settingsSlice = make([]settings.Settings, len(hosts))
for i, host := range hosts {
settingsSlice[i], err = settingsConstructor(rawSettings, common.Domain, host, ipVersion, common.NoDNSLookup, matcher)
if err != nil {
return nil, warnings, err
}
}
return settingsSlice, warnings, nil
}

View File

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

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

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

View File

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

61
internal/regex/regex.go Normal file
View File

@@ -0,0 +1,61 @@
package regex
import "regexp"
type Matcher interface {
GodaddyKey(s string) bool
GodaddySecret(s string) bool
DuckDNSToken(s string) bool
NamecheapPassword(s string) bool
DreamhostKey(s string) bool
CloudflareKey(s string) bool
CloudflareUserServiceKey(s string) bool
}
type matcher struct {
goDaddyKey, goDaddySecret, duckDNSToken, namecheapPassword, dreamhostKey, cloudflareKey, cloudflareUserServiceKey *regexp.Regexp
}
//nolint:gocritic
func NewMatcher() (m Matcher, err error) {
matcher := &matcher{}
matcher.goDaddyKey, err = regexp.Compile(`^[A-Za-z0-9]{10,14}\_[A-Za-z0-9]{22}$`)
if err != nil {
return nil, err
}
matcher.goDaddySecret, err = regexp.Compile(`^[A-Za-z0-9]{22}$`)
if err != nil {
return nil, err
}
matcher.duckDNSToken, err = regexp.Compile(`^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$`)
if err != nil {
return nil, err
}
matcher.namecheapPassword, err = regexp.Compile(`^[a-f0-9]{32}$`)
if err != nil {
return nil, err
}
matcher.dreamhostKey, err = regexp.Compile(`^[a-zA-Z0-9]{16}$`)
if err != nil {
return nil, err
}
matcher.cloudflareKey, err = regexp.Compile(`^[a-zA-Z0-9]+$`)
if err != nil {
return nil, err
}
matcher.cloudflareUserServiceKey, err = regexp.Compile(`^v1\.0.+$`)
if err != nil {
return nil, err
}
return matcher, nil
}
func (m *matcher) GodaddyKey(s string) bool { return m.goDaddyKey.MatchString(s) }
func (m *matcher) GodaddySecret(s string) bool { return m.goDaddySecret.MatchString(s) }
func (m *matcher) DuckDNSToken(s string) bool { return m.duckDNSToken.MatchString(s) }
func (m *matcher) NamecheapPassword(s string) bool { return m.namecheapPassword.MatchString(s) }
func (m *matcher) DreamhostKey(s string) bool { return m.dreamhostKey.MatchString(s) }
func (m *matcher) CloudflareKey(s string) bool { return m.cloudflareKey.MatchString(s) }
func (m *matcher) CloudflareUserServiceKey(s string) bool {
return m.cloudflareUserServiceKey.MatchString(s)
}

View File

@@ -0,0 +1,262 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/network"
"github.com/qdm12/ddns-updater/internal/regex"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type cloudflare struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
key string
token string
email string
userServiceKey string
zoneIdentifier string
proxied bool
ttl uint
matcher regex.Matcher
}
func NewCloudflare(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Key string `json:"key"`
Token string `json:"token"`
Email string `json:"email"`
UserServiceKey string `json:"user_service_key"`
ZoneIdentifier string `json:"zone_identifier"`
Proxied bool `json:"proxied"`
TTL uint `json:"ttl"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
c := &cloudflare{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
key: extraSettings.Key,
token: extraSettings.Token,
email: extraSettings.Email,
userServiceKey: extraSettings.UserServiceKey,
zoneIdentifier: extraSettings.ZoneIdentifier,
proxied: extraSettings.Proxied,
ttl: extraSettings.TTL,
matcher: matcher,
}
if err := c.isValid(); err != nil {
return nil, err
}
return c, nil
}
func (c *cloudflare) isValid() error {
switch {
case len(c.key) > 0: // email and key must be provided
switch {
case !c.matcher.CloudflareKey(c.key):
return fmt.Errorf("invalid key format")
case !verification.NewVerifier().MatchEmail(c.email):
return fmt.Errorf("invalid email format")
}
case len(c.userServiceKey) > 0: // only user service key
if !c.matcher.CloudflareKey(c.key) {
return fmt.Errorf("invalid user service key format")
}
default: // API token only
}
switch {
case len(c.zoneIdentifier) == 0:
return fmt.Errorf("zone identifier cannot be empty")
case c.ttl == 0:
return fmt.Errorf("TTL cannot be left to 0")
}
return nil
}
func (c *cloudflare) String() string {
return toString(c.domain, c.host, constants.CLOUDFLARE, c.ipVersion)
}
func (c *cloudflare) Domain() string {
return c.domain
}
func (c *cloudflare) Host() string {
return c.host
}
func (c *cloudflare) IPVersion() models.IPVersion {
return c.ipVersion
}
func (c *cloudflare) DNSLookup() bool {
return c.dnsLookup
}
func (c *cloudflare) BuildDomainName() string {
return buildDomainName(c.host, c.domain)
}
func (c *cloudflare) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", c.BuildDomainName(), c.BuildDomainName())),
Host: models.HTML(c.Host()),
Provider: "<a href=\"https://www.cloudflare.com\">Cloudflare</a>",
IPVersion: models.HTML(c.ipVersion),
}
}
func setHeaders(r *http.Request, token, userServiceKey, email, key string) {
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
switch {
case len(token) > 0:
r.Header.Set("Authorization", "Bearer "+token)
case len(userServiceKey) > 0:
r.Header.Set("X-Auth-User-Service-Key", userServiceKey)
case len(email) > 0 && len(key) > 0:
r.Header.Set("X-Auth-Email", email)
r.Header.Set("X-Auth-Key", key)
}
}
// Obtain domain identifier
// See https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records
func (c *cloudflare) getRecordIdentifier(client netlib.Client, newIP net.IP) (identifier string, upToDate bool, err error) {
recordType := A
if newIP.To4() == nil {
recordType = AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.cloudflare.com",
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records", c.zoneIdentifier),
}
values := url.Values{}
values.Set("type", recordType)
values.Set("name", c.BuildDomainName())
values.Set("page", "1")
values.Set("per_page", "1")
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return "", false, err
}
setHeaders(r, c.token, c.userServiceKey, c.email, c.key)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return "", false, err
} else if status != http.StatusOK {
return "", false, fmt.Errorf("HTTP status %d", status)
}
listRecordsResponse := struct {
Success bool `json:"success"`
Errors []string `json:"errors"`
Result []struct {
ID string `json:"id"`
Content string `json:"content"`
} `json:"result"`
}{}
if err := json.Unmarshal(content, &listRecordsResponse); err != nil {
return "", false, err
}
switch {
case len(listRecordsResponse.Errors) > 0:
return "", false, fmt.Errorf(strings.Join(listRecordsResponse.Errors, ","))
case !listRecordsResponse.Success:
return "", false, fmt.Errorf("request to Cloudflare not successful")
case len(listRecordsResponse.Result) == 0:
return "", false, fmt.Errorf("received no result from Cloudflare")
case len(listRecordsResponse.Result) > 1:
return "", false, fmt.Errorf("received %d results instead of 1 from Cloudflare", len(listRecordsResponse.Result))
case listRecordsResponse.Result[0].Content == newIP.String(): // up to date
return "", true, nil
}
return listRecordsResponse.Result[0].ID, false, nil
}
func (c *cloudflare) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
identifier, upToDate, err := c.getRecordIdentifier(client, ip)
if err != nil {
return nil, err
} else if upToDate {
return ip, nil
}
type cloudflarePutBody struct {
Type string `json:"type"` // A or AAAA depending on ip address given
Name string `json:"name"` // DNS record name i.e. example.com
Content string `json:"content"` // ip address
Proxied bool `json:"proxied"` // whether the record is receiving the performance and security benefits of Cloudflare
TTL uint `json:"ttl"`
}
u := url.URL{
Scheme: "https",
Host: "api.cloudflare.com",
Path: fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", c.zoneIdentifier, identifier),
}
r, err := network.BuildHTTPPut(
u.String(),
cloudflarePutBody{
Type: recordType,
Name: c.host,
Content: ip.String(),
Proxied: c.proxied,
TTL: c.ttl,
},
)
if err != nil {
return nil, err
}
setHeaders(r, c.token, c.userServiceKey, c.email, c.key)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status > http.StatusUnsupportedMediaType {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedJSON struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result struct {
Content string `json:"content"`
} `json:"result"`
}
if err := json.Unmarshal(content, &parsedJSON); err != nil {
return nil, err
} else if !parsedJSON.Success {
var errStr string
for _, e := range parsedJSON.Errors {
errStr += fmt.Sprintf("error %d: %s; ", e.Code, e.Message)
}
return nil, fmt.Errorf(errStr)
}
newIP = net.ParseIP(parsedJSON.Result.Content)
if newIP == nil {
return nil, fmt.Errorf("new IP %q is malformed", parsedJSON.Result.Content)
} else if !newIP.Equal(ip) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

View File

@@ -0,0 +1,9 @@
package settings
const (
badauth = "badauth"
success = "success"
nohost = "nohost"
A = "A"
AAAA = "AAAA"
)

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

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

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

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

View File

@@ -0,0 +1,155 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
netlib "github.com/qdm12/golibs/network"
)
//nolint:maligned
type donDominio struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
name string
}
func NewDonDominio(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
if len(host) == 0 {
host = "@" // default
}
d := &donDominio{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
name: extraSettings.Name,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *donDominio) isValid() error {
switch {
case len(d.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(d.password) == 0:
return fmt.Errorf("password cannot be empty")
case len(d.name) == 0:
return fmt.Errorf("name cannot be empty")
case d.host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func (d *donDominio) String() string {
return toString(d.domain, d.host, constants.DONDOMINIO, d.ipVersion)
}
func (d *donDominio) Domain() string {
return d.domain
}
func (d *donDominio) Host() string {
return d.host
}
func (d *donDominio) DNSLookup() bool {
return d.dnsLookup
}
func (d *donDominio) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *donDominio) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *donDominio) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://www.dondominio.com/\">DonDominio</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *donDominio) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "simple-api.dondominio.net",
}
values := url.Values{}
values.Set("apiuser", d.username)
values.Set("apipasswd", d.password)
values.Set("domain", d.domain)
values.Set("name", d.name)
isIPv4 := ip.To4() != nil
if isIPv4 {
values.Set("ipv4", ip.String())
} else {
values.Set("ipv6", ip.String())
}
r, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentid.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
response := struct {
Success bool `json:"success"`
ErrorCode int `json:"errorCode"`
ErrorCodeMessage string `json:"errorCodeMsg"`
ResponseData struct {
GlueRecords []struct {
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
} `json:"gluerecords"`
} `json:"responseData"`
}{}
if err := json.Unmarshal(content, &response); err != nil {
return nil, err
}
if !response.Success {
return nil, fmt.Errorf("%s (error code %d)", response.ErrorCodeMessage, response.ErrorCode)
}
ipString := response.ResponseData.GlueRecords[0].IPv4
if !isIPv4 {
ipString = response.ResponseData.GlueRecords[0].IPv6
}
newIP = net.ParseIP(ipString)
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", ipString)
}
return newIP, nil
}

View File

@@ -1,4 +1,4 @@
package update
package settings
import (
"encoding/json"
@@ -8,10 +8,117 @@ import (
"net/url"
"github.com/google/uuid"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
"github.com/qdm12/golibs/network"
)
const success = "success"
type dreamhost struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
key string
matcher regex.Matcher
}
func NewDreamhost(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Key string `json:"key"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
if len(host) == 0 {
host = "@" // default
}
d := &dreamhost{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
key: extraSettings.Key,
matcher: matcher,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *dreamhost) isValid() error {
switch {
case !d.matcher.DreamhostKey(d.key):
return fmt.Errorf("invalid key format")
case d.host != "@":
return fmt.Errorf(`host can only be "@"`)
}
return nil
}
func (d *dreamhost) String() string {
return toString(d.domain, d.host, constants.DREAMHOST, d.ipVersion)
}
func (d *dreamhost) Domain() string {
return d.domain
}
func (d *dreamhost) Host() string {
return d.host
}
func (d *dreamhost) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *dreamhost) DNSLookup() bool {
return d.dnsLookup
}
func (d *dreamhost) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *dreamhost) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://www.dreamhost.com/\">Dreamhost</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *dreamhost) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
records, err := listDreamhostRecords(client, d.key)
if err != nil {
return nil, err
}
var oldIP net.IP
for _, data := range records.Data {
if data.Type == recordType && data.Record == d.BuildDomainName() {
if data.Editable == "0" {
return nil, fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return ip, nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
if err := removeDreamhostRecord(client, d.key, d.domain, oldIP); err != nil {
return nil, err
}
}
return ip, addDreamhostRecord(client, d.key, d.domain, ip)
}
type (
dreamHostRecords struct {
@@ -64,6 +171,10 @@ func listDreamhostRecords(client network.Client, key string) (records dreamHostR
}
func removeDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
@@ -71,7 +182,7 @@ func removeDreamhostRecord(client network.Client, key, domain string, ip net.IP)
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-remove_record")
values.Set("record", domain)
values.Set("type", "A")
values.Set("type", recordType)
values.Set("value", ip.String())
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
@@ -95,6 +206,10 @@ func removeDreamhostRecord(client network.Client, key, domain string, ip net.IP)
}
func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
@@ -102,7 +217,7 @@ func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) er
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-add_record")
values.Set("record", domain)
values.Set("type", "A")
values.Set("type", recordType)
values.Set("value", ip.String())
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
@@ -124,32 +239,3 @@ func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) er
}
return nil
}
func updateDreamhost(client network.Client, domain, key, domainName string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
records, err := listDreamhostRecords(client, key)
if err != nil {
return err
}
var oldIP net.IP
for _, data := range records.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
if err := removeDreamhostRecord(client, key, domain, oldIP); err != nil {
return err
}
}
return addDreamhostRecord(client, key, domain, ip)
}

View File

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

137
internal/settings/dyn.go Normal file
View File

@@ -0,0 +1,137 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
"github.com/qdm12/golibs/network"
)
//nolint:maligned
type dyn struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
useProviderIP bool
}
func NewDyn(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
d := &dyn{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := d.isValid(); err != nil {
return nil, err
}
return d, nil
}
func (d *dyn) isValid() error {
switch {
case len(d.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(d.password) == 0:
return fmt.Errorf("password cannot be empty")
case d.host == "*":
return fmt.Errorf(`host cannot be "*"`)
}
return nil
}
func (d *dyn) String() string {
return fmt.Sprintf("[domain: %s | host: %s | provider: Dyn]", d.domain, d.host)
}
func (d *dyn) Domain() string {
return d.domain
}
func (d *dyn) Host() string {
return d.host
}
func (d *dyn) IPVersion() models.IPVersion {
return d.ipVersion
}
func (d *dyn) DNSLookup() bool {
return d.dnsLookup
}
func (d *dyn) BuildDomainName() string {
return buildDomainName(d.host, d.domain)
}
func (d *dyn) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", d.BuildDomainName(), d.BuildDomainName())),
Host: models.HTML(d.Host()),
Provider: "<a href=\"https://dyn.com/\">Dyn DNS</a>",
IPVersion: models.HTML(d.ipVersion),
}
}
func (d *dyn) Update(client network.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
User: url.UserPassword(d.username, d.password),
Host: "members.dyndns.org",
Path: "/v3/update",
}
values := url.Values{}
switch d.host {
case "@":
values.Set("hostname", d.domain)
default:
values.Set("hostname", fmt.Sprintf("%s.%s", d.host, d.domain))
}
if !d.useProviderIP {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
s := string(content)
switch {
case strings.HasPrefix(s, "notfqdn"):
return nil, fmt.Errorf("fully qualified domain name is not valid")
case strings.HasPrefix(s, "badrequest"):
return nil, fmt.Errorf("bad request")
case strings.HasPrefix(s, "good"):
return ip, nil
default:
return nil, fmt.Errorf("unknown response: %s", s)
}
}

View File

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

157
internal/settings/google.go Normal file
View File

@@ -0,0 +1,157 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type google struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
username string
password string
useProviderIP bool
}
func NewGoogle(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Username string `json:"username"`
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
g := &google{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
username: extraSettings.Username,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := g.isValid(); err != nil {
return nil, err
}
return g, nil
}
func (g *google) isValid() error {
switch {
case len(g.username) == 0:
return fmt.Errorf("username cannot be empty")
case len(g.password) == 0:
return fmt.Errorf("password cannot be empty")
}
return nil
}
func (g *google) String() string {
return toString(g.domain, g.host, constants.GOOGLE, g.ipVersion)
}
func (g *google) Domain() string {
return g.domain
}
func (g *google) Host() string {
return g.host
}
func (g *google) DNSLookup() bool {
return g.dnsLookup
}
func (g *google) IPVersion() models.IPVersion {
return g.ipVersion
}
func (g *google) BuildDomainName() string {
return buildDomainName(g.host, g.domain)
}
func (g *google) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", g.BuildDomainName(), g.BuildDomainName())),
Host: models.HTML(g.Host()),
Provider: "<a href=\"https://domains.google.com/m/registrar\">Google</a>",
IPVersion: models.HTML(g.ipVersion),
}
}
func (g *google) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
u := url.URL{
Scheme: "https",
Host: "domains.google.com",
Path: "/nic/update",
User: url.UserPassword(g.username, g.password),
}
values := url.Values{}
fqdn := g.BuildDomainName()
values.Set("hostname", fqdn)
if !g.useProviderIP {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentig.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 nohost:
return nil, fmt.Errorf("hostname does not exist")
case badauth:
return nil, fmt.Errorf("invalid username password combination")
case "notfqdn":
return nil, fmt.Errorf("hostname %q is not a valid fully qualified domain name", fqdn)
case "badagent":
return nil, fmt.Errorf("user agent is banned")
case "abuse":
return nil, fmt.Errorf("username is banned due to abuse")
case "911":
return nil, fmt.Errorf("Google's internal server error 911")
case "conflict A":
return nil, fmt.Errorf("custom A record conflicts with the update")
case "conflict AAAA":
return nil, fmt.Errorf("custom AAAA record conflicts with the update")
}
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
ipsV4 := verification.NewVerifier().SearchIPv4(s)
ipsV6 := verification.NewVerifier().SearchIPv6(s)
ips := append(ipsV4, ipsV6...)
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 !g.useProviderIP && !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)
}

138
internal/settings/he.go Normal file
View File

@@ -0,0 +1,138 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/regex"
netlib "github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
//nolint:maligned
type he struct {
domain string
host string
ipVersion models.IPVersion
dnsLookup bool
password string
useProviderIP bool
}
func NewHe(data json.RawMessage, domain, host string, ipVersion models.IPVersion, noDNSLookup bool, matcher regex.Matcher) (s Settings, err error) {
extraSettings := struct {
Password string `json:"password"`
UseProviderIP bool `json:"provider_ip"`
}{}
if err := json.Unmarshal(data, &extraSettings); err != nil {
return nil, err
}
h := &he{
domain: domain,
host: host,
ipVersion: ipVersion,
dnsLookup: !noDNSLookup,
password: extraSettings.Password,
useProviderIP: extraSettings.UseProviderIP,
}
if err := h.isValid(); err != nil {
return nil, err
}
return h, nil
}
func (h *he) isValid() error {
if len(h.password) == 0 {
return fmt.Errorf("password cannot be empty")
}
return nil
}
func (h *he) String() string {
return toString(h.domain, h.host, constants.HE, h.ipVersion)
}
func (h *he) Domain() string {
return h.domain
}
func (h *he) Host() string {
return h.host
}
func (h *he) DNSLookup() bool {
return h.dnsLookup
}
func (h *he) IPVersion() models.IPVersion {
return h.ipVersion
}
func (h *he) BuildDomainName() string {
return buildDomainName(h.host, h.domain)
}
func (h *he) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: models.HTML(fmt.Sprintf("<a href=\"http://%s\">%s</a>", h.BuildDomainName(), h.BuildDomainName())),
Host: models.HTML(h.Host()),
Provider: "<a href=\"https://dns.he.net/\">he.net</a>",
IPVersion: models.HTML(h.ipVersion),
}
}
func (h *he) Update(client netlib.Client, ip net.IP) (newIP net.IP, err error) {
fqdn := h.BuildDomainName()
u := url.URL{
Scheme: "https",
Host: "dyn.dns.he.net",
Path: "/nic/update",
User: url.UserPassword(fqdn, h.password),
}
values := url.Values{}
values.Set("hostname", fqdn)
if !h.useProviderIP {
values.Set("myip", ip.String())
}
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentih.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 badauth:
return nil, fmt.Errorf("invalid username password combination")
}
if strings.Contains(s, "nochg") || strings.Contains(s, "good") {
verifier := verification.NewVerifier()
ipsV4 := verifier.SearchIPv4(s)
ipsV6 := verifier.SearchIPv6(s)
ips := append(ipsV4, ipsV6...)
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 !h.useProviderIP && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}
return nil, fmt.Errorf("invalid response %q", s)
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

@@ -0,0 +1,240 @@
package update
import (
"context"
"net"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/data"
"github.com/qdm12/ddns-updater/internal/models"
librecords "github.com/qdm12/ddns-updater/internal/records"
"github.com/qdm12/golibs/logging"
)
type Runner interface {
Run(ctx context.Context, period time.Duration) (forceUpdate func())
}
type runner struct {
db data.Database
updater Updater
netLookupIP func(hostname string) ([]net.IP, error)
ipGetter IPGetter
logger logging.Logger
timeNow func() time.Time
}
func NewRunner(db data.Database, updater Updater, ipGetter IPGetter, logger logging.Logger, timeNow func() time.Time) Runner {
return &runner{
db: db,
updater: updater,
netLookupIP: net.LookupIP,
ipGetter: ipGetter,
logger: logger,
timeNow: timeNow,
}
}
func (r *runner) lookupIPs(hostname string) (ipv4 net.IP, ipv6 net.IP, err error) {
ips, err := r.netLookupIP(hostname)
if err != nil {
return nil, nil, err
}
for _, ip := range ips {
if ip.To4() == nil {
ipv6 = ip
} else {
ipv4 = ip
}
}
return ipv4, ipv6, nil
}
func doIPVersion(records []librecords.Record) (doIP, doIPv4, doIPv6 bool) {
for _, record := range records {
switch record.Settings.IPVersion() {
case constants.IPv4OrIPv6:
doIP = true
case constants.IPv4:
doIPv4 = true
case constants.IPv6:
doIPv6 = true
}
if doIP && doIPv4 && doIPv6 {
return true, true, true
}
}
return doIP, doIPv4, doIPv6
}
func (r *runner) getNewIPs(doIP, doIPv4, doIPv6 bool) (ip, ipv4, ipv6 net.IP, errors []error) {
var err error
if doIP {
ip, err = r.ipGetter.IP()
if err != nil {
errors = append(errors, err)
}
}
if doIPv4 {
ipv4, err = r.ipGetter.IPv4()
if err != nil {
errors = append(errors, err)
}
}
if doIPv6 {
ipv6, err = r.ipGetter.IPv6()
if err != nil {
errors = append(errors, err)
}
}
return ip, ipv4, ipv6, errors
}
func (r *runner) getRecordIDsToUpdate(records []librecords.Record, ip, ipv4, ipv6 net.IP) (recordIDs map[int]struct{}) {
recordIDs = make(map[int]struct{})
for id, record := range records {
if shouldUpdate := r.shouldUpdateRecord(record, ip, ipv4, ipv6); shouldUpdate {
recordIDs[id] = struct{}{}
}
}
return recordIDs
}
func (r *runner) shouldUpdateRecord(record librecords.Record, ip, ipv4, ipv6 net.IP) (update bool) {
hostname := record.Settings.BuildDomainName()
ipVersion := record.Settings.IPVersion()
if !record.Settings.DNSLookup() {
lastIP := record.History.GetCurrentIP() // can be nil
return r.shouldUpdateRecordNoLookup(hostname, ipVersion, lastIP, ip, ipv4, ipv6)
}
return r.shouldUpdateRecordWithLookup(hostname, ipVersion, ip, ipv4, ipv6)
}
func (r *runner) shouldUpdateRecordNoLookup(hostname string, ipVersion models.IPVersion, lastIP, ip, ipv4, ipv6 net.IP) (update bool) {
switch ipVersion {
case constants.IPv4OrIPv6:
if ip != nil && !ip.Equal(lastIP) {
r.logger.Info("Last IP address stored for %s is %s and your IP address is %s", hostname, lastIP, ip)
return true
}
case constants.IPv4:
if ipv4 != nil && !ipv4.Equal(lastIP) {
r.logger.Info("Last IPv4 address stored for %s is %s and your IPv4 address is %s", hostname, lastIP, ip)
return true
}
case constants.IPv6:
if ipv6 != nil && !ipv6.Equal(lastIP) {
r.logger.Info("Last IPv6 address stored for %s is %s and your IPv6 address is %s", hostname, lastIP, ip)
return true
}
}
return false
}
func (r *runner) shouldUpdateRecordWithLookup(hostname string, ipVersion models.IPVersion, ip, ipv4, ipv6 net.IP) (update bool) {
recordIPv4, recordIPv6, err := r.lookupIPs(hostname)
if err != nil {
r.logger.Warn(err) // update anyway
}
switch ipVersion {
case constants.IPv4OrIPv6:
if ip != nil && !ip.Equal(recordIPv4) && !ip.Equal(recordIPv6) {
recordIP := recordIPv4
if ip.To4() == nil {
recordIP = recordIPv6
}
r.logger.Info("IP address of %s is %s and your IP address is %s", hostname, recordIP, ip)
return true
}
case constants.IPv4:
if ipv4 != nil && !ipv4.Equal(recordIPv4) {
r.logger.Info("IPv4 address of %s is %s and your IPv4 address is %s", hostname, recordIPv4, ipv4)
return true
}
case constants.IPv6:
if ipv6 != nil && !ipv6.Equal(recordIPv6) {
r.logger.Info("IPv6 address of %s is %s and your IPv6 address is %s", hostname, recordIPv6, ipv6)
return true
}
}
return false
}
func getIPMatchingVersion(ip, ipv4, ipv6 net.IP, ipVersion models.IPVersion) net.IP {
switch ipVersion {
case constants.IPv4OrIPv6:
return ip
case constants.IPv4:
return ipv4
case constants.IPv6:
return ipv6
}
return nil
}
func setInitialUpToDateStatus(db data.Database, id int, updateIP net.IP, now time.Time) error {
record, err := db.Select(id)
if err != nil {
return err
}
record.Status = constants.UPTODATE
record.Time = now
if record.History.GetCurrentIP() == nil {
record.History = append(record.History, models.HistoryEvent{
IP: updateIP,
Time: now,
})
}
return db.Update(id, record)
}
func (r *runner) updateNecessary() {
records := r.db.SelectAll()
doIP, doIPv4, doIPv6 := doIPVersion(records)
ip, ipv4, ipv6, errors := r.getNewIPs(doIP, doIPv4, doIPv6)
for _, err := range errors {
r.logger.Error(err)
}
recordIDs := r.getRecordIDsToUpdate(records, ip, ipv4, ipv6)
now := r.timeNow()
for id, record := range records {
_, requireUpdate := recordIDs[id]
if requireUpdate || record.Status != constants.UNSET {
continue
}
updateIP := getIPMatchingVersion(ip, ipv4, ipv6, record.Settings.IPVersion())
if err := setInitialUpToDateStatus(r.db, id, updateIP, now); err != nil {
r.logger.Error(err)
}
}
for id := range recordIDs {
record := records[id]
updateIP := getIPMatchingVersion(ip, ipv4, ipv6, record.Settings.IPVersion())
r.logger.Info("Updating record %s to use %s", record.Settings, updateIP)
if err := r.updater.Update(id, updateIP, r.timeNow()); err != nil {
r.logger.Error(err)
}
}
}
func (r *runner) Run(ctx context.Context, period time.Duration) (forceUpdate func()) {
timer := time.NewTicker(period)
forceChannel := make(chan struct{})
go func() {
for {
select {
case <-timer.C:
r.updateNecessary()
case <-forceChannel:
r.updateNecessary()
case <-ctx.Done():
timer.Stop()
return
}
}
}()
return func() {
forceChannel <- struct{}{}
}
}

View File

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

View File

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

View File

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