62 Commits
v1 ... 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
Quentin McGaw
af68f9ba0f Fix #54 periodic backup to zip files 2020-05-11 23:11:48 +00:00
Thomas Raddatz
f7171e4b01 Updated Cloudflare usage instructions. (#52) 2020-05-11 18:11:59 -04:00
Quentin McGaw
0c028f70e9 Using url package to build urls for APIs (#57) 2020-05-11 18:11:20 -04:00
Quentin McGaw
c194681856 Update dependencies 2020-05-10 17:01:25 +00:00
Quentin McGaw
9c31616b46 Refactored main function 2020-05-10 17:01:09 +00:00
Quentin McGaw
55668d0310 Actualise dockerignore 2020-05-08 00:34:11 +00:00
Quentin McGaw
3bdb8ba5ac Update devcontainer lint settings 2020-05-08 00:34:00 +00:00
Quentin McGaw
345cc754ff Update golibs 2020-05-08 00:33:51 +00:00
Quentin McGaw
9e05c6164d Update golang to 1.14 2020-05-08 00:30:49 +00:00
Quentin McGaw
ea79ca53ea Update Golangci-lint to 1.26.0 2020-05-08 00:30:42 +00:00
Quentin McGaw
6a3c280f30 Buildx readme badge 2020-04-05 02:36:42 +00:00
Quentin McGaw
01e982a4cd Golangci-lint buildx fix
- Timeout of 10 minutes
- Run golangci-lint after tests and build
- Removed arch armv6 and ppc64le (too slow for golangci-lint)
2020-04-05 01:27:35 +00:00
Quentin McGaw
99d33bbcf9 Golangci lint and fixing lint issues (#48) 2020-04-04 16:38:10 -04:00
Quentin McGaw
e38351e5a4 Remove sqlite (#46)
- Removed support for SQLite based database
- Removed migration from sqlite to json file persistence storage
- Updated announcement
- Scratch based Docker image
- Much faster rebuilds
- ARM v6 and ppc64le CPU architectures added
2020-04-04 14:11:59 -04:00
94 changed files with 4651 additions and 2732 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": {
@@ -43,10 +42,58 @@
"deepCompletion": true,
"usePlaceholders": false
},
"go.lintTool": "golangci-lint",
"go.lintFlags": [
"--fast",
"--enable",
"rowserrcheck",
"--enable",
"bodyclose",
"--enable",
"dogsled",
"--enable",
"dupl",
"--enable",
"gochecknoglobals",
"--enable",
"gochecknoinits",
"--enable",
"gocognit",
"--enable",
"goconst",
"--enable",
"gocritic",
"--enable",
"gocyclo",
"--enable",
"goimports",
"--enable",
"golint",
"--enable",
"gosec",
"--enable",
"interfacer",
"--enable",
"maligned",
"--enable",
"misspell",
"--enable",
"nakedret",
"--enable",
"prealloc",
"--enable",
"scopelint",
"--enable",
"unconvert",
"--enable",
"unparam",
"--enable",
"whitespace"
],
// Golang on save
"go.buildOnSave": "package",
"go.lintOnSave": "package",
"go.vetOnSave": "package",
"go.buildOnSave": "workspace",
"go.lintOnSave": "workspace",
"go.vetOnSave": "workspace",
"editor.formatOnSave": true,
"[go]": {
"editor.codeActionsOnSave": {

View File

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

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

2
.gitignore vendored
View File

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

46
.golangci.yml Normal file
View File

@@ -0,0 +1,46 @@
linters-settings:
maligned:
suggest-new: true
misspell:
locale: US
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- dogsled
- dupl
- errcheck
- gochecknoglobals
- gochecknoinits
- gocognit
- goconst
- gocritic
- gocyclo
- goimports
- golint
- gosec
- gosimple
- govet
- ineffassign
- interfacer
- maligned
- misspell
- nakedret
- prealloc
- rowserrcheck
- scopelint
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- whitespace
run:
skip-dirs:
- .devcontainer
- .github

View File

@@ -1,20 +1,31 @@
ARG ALPINE_VERSION=3.11
ARG GO_VERSION=1.13
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
RUN apk --update add git g++
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 CGO_ENABLED=0 go test ./...
RUN CGO_ENABLED=1 go build -a -installsuffix cgo -ldflags="-s -w" -o app
RUN go test ./...
RUN go build -trimpath -ldflags="-s -w" -o app
RUN golangci-lint run --timeout=10m
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 \
@@ -24,26 +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"
RUN apk add --update sqlite ca-certificates && \
mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 && \
rm -rf /var/cache/apk/* && \
# Creating empty database file in case nothing is mounted
mkdir -p /updater/data && \
chown -R 1000 /updater && \
chmod 700 /updater/data
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=
GOTIFY_TOKEN= \
TZ=
COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app
COPY --chown=1000 ui/* /updater/ui/

387
README.md
View File

@@ -1,12 +1,10 @@
# 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*
**SQLite migration support will be removed on 1 April 2020, so be sure to update your image before that**
*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)
[![Build Status](https://travis-ci.org/qdm12/ddns-updater.svg?branch=master)](https://travis-ci.org/qdm12/ddns-updater)
[![Build status](https://github.com/qdm12/ddns-updater/workflows/Buildx%20latest/badge.svg)](https://github.com/qdm12/ddns-updater/actions?query=workflow%3A%22Buildx+latest%22)
[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater)
[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/ddns-updater.svg)](https://hub.docker.com/r/qmcgaw/ddns-updater)
[![Image size](https://images.microbadger.com/badges/image/qmcgaw/ddns-updater.svg)](https://microbadger.com/images/qmcgaw/ddns-updater)
@@ -19,21 +17,66 @@
## 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)
- Lightweight based on a Go binary and *Alpine 3.11* with Sqlite and Ca-Certificates packages
- 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
- Sends notifications to your Android phone, see the [**Gotify**](#Gotify) section (it's free, open source and self hosted 🆒)
- Compatible with `amd64`, `386`, `arm64` and `arm32v7` (Raspberry Pis) CPU architectures.
- Compatible with `amd64`, `386`, `arm64`, `arm32v7` (Raspberry Pis) CPU architectures.
## Setup
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
@@ -49,167 +92,199 @@
*(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:
```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"`
- `"identifier"`
- `"zone_identifier"` is the Zone ID of your site
- `"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:
- Email `"email"` and key `"key"`
- Email `"email"` and Global API Key `"key"`
- User service key `"user_service_key"`
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone.
- *Optionally*, `"proxied"` can be `true` or `false` to use the proxy services of Cloudflare
- `"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) |
| `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 |
| `LISTENING_PORT` | `8000` | Internal TCP listening port for the web UI |
| `ROOT_URL` | `/` | URL path to append to all paths to the webUI (i.e. `/ddns` for accessing `https://example.com/ddns` through a proxy) |
| `BACKUP_PERIOD` | `0` | Set to a period (i.e. `72h15m`) to enable zip backups of data/config.json and data/updates.json in a zip file |
| `BACKUP_DIRECTORY` | `/updater/data` | Directory to write backup zip files to if `BACKUP_PERIOD` is not `0`.
| `LOG_ENCODING` | `console` | Format of logging, `json` or `console` |
| `LOG_LEVEL` | `info` | Level of logging, `info`, `warning` or `error` |
| `NODE_ID` | `0` | Node ID (for distributed systems), can be any integer |
| `HTTP_TIMEOUT` | `10s` | Timeout for all HTTP requests |
| `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
@@ -222,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
@@ -319,68 +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
- [ ] 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

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

View File

@@ -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,13 +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=
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.4
github.com/google/uuid v1.1.1
github.com/kyokomi/emoji v2.1.0+incompatible
github.com/mattn/go-sqlite3 v1.10.0
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052
github.com/stretchr/testify v1.4.0
github.com/kyokomi/emoji v2.2.4+incompatible
github.com/qdm12/golibs v0.0.0-20200712151944-a0325873bf5a
github.com/stretchr/testify v1.6.1
)

32
go.sum
View File

@@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb h1:D4uzjWwKYQ5XnAvUbuvHW93esHg7F8N/OYeBBcJoTr0=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI=
@@ -35,6 +37,10 @@ github.com/go-openapi/swag v0.17.0 h1:iqrgMg7Q7SvtbWLlltPrkMs0UBJI6oTSs79JFRUi88
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/validate v0.17.0 h1:pqoViQz3YLOGIhAmD0N4Lt6pa/3Gnj3ymKqQwq8iS6U=
github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/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=
@@ -49,12 +55,15 @@ 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.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-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc=
@@ -67,8 +76,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052 h1:6KVUzd4oLyHcmmYsXdpE3HR9FUSx3FF8UBTRlJPCKLc=
github.com/qdm12/golibs v0.0.0-20200224233831-af1ada8e2052/go.mod h1:YULaFjj6VGmhjak6f35sUWwEleHUmngN5IQ3kdvd6XE=
github.com/qdm12/golibs v0.0.0-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=
@@ -77,6 +86,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.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=
@@ -100,10 +111,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs=
@@ -117,5 +133,9 @@ 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=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

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

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

View File

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

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}`
RegexDuckDNSToken = `[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}`
namecheapPassword = `[a-f0-9]{32}`
dreamhostKey = `[a-zA-Z0-9]{16}`
cloudflareKey = `[a-zA-Z0-9]+`
cloudflareUserServiceKey = `v1\.0.+`
cloudflareToken = `[a-zA-Z0-9_]{40}`
)
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("^" + RegexDuckDNSToken + "$").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

@@ -1,10 +1,10 @@
package constants
const (
// Annoucement is a message annoucement
Annoucement = "Added support for DDNSS.de"
// AnnoucementExpiration is the expiration date of the annoucement in format yyyy-mm-dd
AnnoucementExpiration = "2020-03-20"
// Announcement is a message announcement
Announcement = "Support for he.net"
// AnnouncementExpiration is the expiration date of the announcement in format yyyy-mm-dd
AnnouncementExpiration = "2020-10-15"
)
const (

View File

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

View File

@@ -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

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

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 {

132
internal/env/env.go vendored
View File

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

View File

@@ -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,133 +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 {
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 = "N/A"
} 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 = "N/A"
}
previousIPs := record.History.GetPreviousIPs()
row.PreviousIPs = "N/A"
if len(previousIPs) > 0 {
var previousIPsStr []string
const maxPreviousIPs = 2
for i, previousIP := range previousIPs {
if i == maxPreviousIPs {
previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
break
}
previousIPsStr = append(previousIPsStr, previousIP.String())
}
row.PreviousIPs = models.HTML(strings.Join(previousIPsStr, ", "))
}
return row
}
func convertStatus(status models.Status) models.HTML {
switch status {
case constants.SUCCESS:
return constants.HTML_SUCCESS
case constants.FAIL:
return constants.HTML_FAIL
case constants.UPTODATE:
return constants.HTML_UPTODATE
case constants.UPDATING:
return constants.HTML_UPDATING
default:
return "Unknown status"
}
}
func convertProvider(provider models.Provider) models.HTML {
switch provider {
case constants.NAMECHEAP:
return constants.HTML_NAMECHEAP
case constants.GODADDY:
return constants.HTML_GODADDY
case constants.DUCKDNS:
return constants.HTML_DUCKDNS
case constants.DREAMHOST:
return constants.HTML_DREAMHOST
case constants.CLOUDFLARE:
return constants.HTML_CLOUDFLARE
case constants.NOIP:
return constants.HTML_NOIP
case constants.DNSPOD:
return constants.HTML_DNSPOD
case constants.INFOMANIAK:
return constants.HTML_INFOMANIAK
case constants.DDNSSDE:
return constants.HTML_DDNSSDE
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.HTML_OPENDNS
case constants.IFCONFIG:
return constants.HTML_IFCONFIG
case constants.IPINFO:
return constants.HTML_IPINFO
case constants.IPIFY:
return constants.HTML_IPIFY
case constants.IPIFY6:
return constants.HTML_IPIFY6
case constants.DDNSS:
return constants.HTML_DDNSS
case constants.DDNSS4:
return constants.HTML_DDNSS4
case constants.DDNSS6:
return constants.HTML_DDNSS6
case constants.CYCLE:
return constants.HTML_CYCLE
default:
return models.HTML(string(IPMethod))
}
}
func convertDomain(domain string) models.HTML {
return models.HTML("<a href=\"http://" + domain + "\">" + domain + "</a>")
}

View File

@@ -1,14 +1,12 @@
package models
type (
// Provider is a possible DNS provider
Provider string
// IPMethod is a method to obtain your public IP address
IPMethod string
// Status is the record config status
Status string
// HTML is for constants HTML strings
HTML string
// IPVersion is ipv4 or ipv6
IPVersion string
)
package models
type (
// Provider is a possible DNS provider
Provider string
// Status is the record config status
Status string
// HTML is for constants HTML strings
HTML string
// IPVersion is ipv4 or ipv6
IPVersion string
)

View File

@@ -1,19 +1,19 @@
package models
// HTMLData is a list of HTML fields to be rendered.
// It is exported so that the HTML template engine can render it.
type HTMLData struct {
Rows []HTMLRow
}
// HTMLRow contains HTML fields to be rendered
// It is exported so that the HTML template engine can render it.
type HTMLRow struct {
Domain HTML
Host HTML
Provider HTML
IPMethod HTML
Status HTML
CurrentIP HTML
PreviousIPs HTML
}
package models
// HTMLData is a list of HTML fields to be rendered.
// It is exported so that the HTML template engine can render it.
type HTMLData struct {
Rows []HTMLRow
}
// HTMLRow contains HTML fields to be rendered
// It is exported so that the HTML template engine can render it.
type HTMLRow struct {
Domain HTML
Host HTML
Provider HTML
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,55 +0,0 @@
package models
import (
"encoding/json"
"time"
)
// Settings contains the elements to update the DNS record
type Settings struct {
Domain string
Host string
Provider Provider
IPMethod IPMethod
IPVersion IPVersion
Delay time.Duration
NoDNSLookup bool
// Provider dependent fields
Password string // Namecheap, Infomaniak, DDNSS and NoIP only
Key string // GoDaddy, Dreamhost and Cloudflare only
Secret string // GoDaddy only
Token string // Cloudflare and DuckDNS only
Email string // Cloudflare only
UserServiceKey string // Cloudflare only
ZoneIdentifier string // Cloudflare only
Identifier string // Cloudflare only
Proxied bool // Cloudflare only
Ttl uint // Cloudflare only
Username string // NoIP, Infomaniak, DDNSS only
}
func (settings *Settings) String() string {
b, _ := json.Marshal(
struct {
Domain string `json:"domain"`
Host string `json:"host"`
Provider string `json:"provider"`
}{
settings.Domain,
settings.Host,
string(settings.Provider),
},
)
return string(b)
}
// BuildDomainName builds the domain name from the domain and the host of the settings
func (settings *Settings) BuildDomainName() string {
if settings.Host == "@" {
return settings.Domain
} else if settings.Host == "*" {
return "any." + settings.Domain
} else {
return settings.Host + "." + settings.Domain
}
}

View File

@@ -1,9 +1,11 @@
package network
import (
"bytes"
"fmt"
"net"
"net/http"
"sort"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
@@ -13,27 +15,95 @@ import (
)
// GetPublicIP downloads a webpage and extracts the IP address from it
func GetPublicIP(client network.Client, URL string, IPVersion models.IPVersion) (ip net.IP, err error) {
content, status, err := client.GetContent(URL)
func GetPublicIP(client network.Client, url string, ipVersion models.IPVersion) (ip net.IP, err error) {
content, status, err := client.GetContent(url)
if err != nil {
return nil, fmt.Errorf("cannot get public %s address from %s: %s", IPVersion, URL, err)
return nil, fmt.Errorf("cannot get public %s address: %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)
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

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

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

View File

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

@@ -1,90 +1,151 @@
package params
import (
"encoding/json"
"fmt"
"time"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
)
type settingsType struct {
Provider string `json:"provider"`
Domain string `json:"domain"`
IPMethod string `json:"ip_method"`
IPVersion string `json:"ip_version"`
Delay uint64 `json:"delay"`
NoDNSLookup bool `json:"no_dns_lookup"`
Host string `json:"host"`
Password string `json:"password"` // Namecheap, NoIP only
Key string `json:"key"` // GoDaddy, Dreamhost and Cloudflare only
Secret string `json:"secret"` // GoDaddy only
Token string `json:"token"` // DuckDNS and Cloudflare only
Email string `json:"email"` // Cloudflare only
Username string `json:"username"` // NoIP only
UserServiceKey string `json:"user_service_key"` // Cloudflare only
ZoneIdentifier string `json:"zone_identifier"` // Cloudflare only
Identifier string `json:"identifier"` // Cloudflare only
Proxied bool `json:"proxied"` // Cloudflare only
Ttl uint `json:"ttl"` // Cloudflare only
}
// GetSettings obtain the update settings from config.json
func (p *params) GetSettings(filePath string) (settings []models.Settings, warnings []string, err error) {
bytes, err := p.readFile(filePath)
if err != nil {
return nil, nil, err
}
var config struct {
Settings []settingsType `json:"settings"`
}
if err := json.Unmarshal(bytes, &config); err != nil {
return nil, nil, err
}
for _, s := range config.Settings {
switch models.Provider(s.Provider) {
case constants.DREAMHOST, constants.DUCKDNS:
s.Host = "@" // only choice available
}
ipMethod := models.IPMethod(s.IPMethod)
// Retro compatibility
if ipMethod == constants.GOOGLE {
p.logger.Warn("IP Method %q is no longer valid, please change it. Defaulting it to %s", constants.GOOGLE, constants.CYCLE)
ipMethod = constants.CYCLE
}
ipVersion := models.IPVersion(s.IPVersion)
if len(ipVersion) == 0 {
ipVersion = constants.IPv4 // default
}
setting := models.Settings{
Provider: models.Provider(s.Provider),
Domain: s.Domain,
Host: s.Host,
IPMethod: ipMethod,
IPVersion: ipVersion,
Delay: time.Second * time.Duration(s.Delay),
NoDNSLookup: s.NoDNSLookup,
Password: s.Password,
Key: s.Key,
Secret: s.Secret,
Token: s.Token,
Email: s.Email,
Username: s.Username,
UserServiceKey: s.UserServiceKey,
ZoneIdentifier: s.ZoneIdentifier,
Identifier: s.Identifier,
Proxied: s.Proxied,
Ttl: s.Ttl,
}
if err := p.isConsistent(setting); err != nil {
warnings = append(warnings, fmt.Sprintf("%s for settings %s", err, setting.String()))
continue
}
settings = append(settings, setting)
}
if len(settings) == 0 {
return nil, warnings, fmt.Errorf("no settings found in config.json")
}
return settings, warnings, nil
}
package params
import (
"encoding/json"
"fmt"
"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 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 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
}
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
}
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
}
if err := json.Unmarshal(jsonBytes, &rawConfig); err != nil {
return nil, nil, err
}
matcher, err := regex.NewMatcher()
if err != nil {
return nil, nil, err
}
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,90 +1,203 @@
package params
import (
"io/ioutil"
"net/url"
"time"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/logging"
libparams "github.com/qdm12/golibs/params"
"github.com/qdm12/golibs/verification"
)
type ParamsReader interface {
GetSettings(filePath string) (settings []models.Settings, warnings []string, err error)
GetDataDir(currentDir string) (string, error)
GetListeningPort() (listeningPort, warning string, err error)
GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error)
GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error)
GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error)
GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error)
GetDelay(setters ...libparams.GetEnvSetter) (duration time.Duration, err error)
GetExeDir() (dir string, err error)
GetHTTPTimeout() (duration time.Duration, err error)
// Version getters
GetVersion() string
GetBuildDate() string
GetVcsRef() string
}
type params struct {
envParams libparams.EnvParams
verifier verification.Verifier
logger logging.Logger
readFile func(filename string) ([]byte, error)
}
func NewParamsReader(logger logging.Logger) ParamsReader {
return &params{
envParams: libparams.NewEnvParams(),
verifier: verification.NewVerifier(),
logger: logger,
readFile: ioutil.ReadFile,
}
}
// GetDataDir obtains the data directory from the environment
// variable DATADIR
func (p *params) GetDataDir(currentDir string) (string, error) {
return p.envParams.GetEnv("DATADIR", libparams.Default(currentDir+"/data"))
}
func (p *params) GetListeningPort() (listeningPort, warning string, err error) {
return p.envParams.GetListeningPort()
}
func (p *params) GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error) {
return p.envParams.GetLoggerConfig()
}
func (p *params) GetGotifyURL(setters ...libparams.GetEnvSetter) (URL *url.URL, err error) {
return p.envParams.GetGotifyURL()
}
func (p *params) GetGotifyToken(setters ...libparams.GetEnvSetter) (token string, err error) {
return p.envParams.GetGotifyToken()
}
func (p *params) GetRootURL(setters ...libparams.GetEnvSetter) (rootURL string, err error) {
return p.envParams.GetRootURL()
}
func (p *params) GetDelay(setters ...libparams.GetEnvSetter) (period time.Duration, err error) {
// Backward compatibility
n, err := p.envParams.GetEnvInt("DELAY", libparams.Compulsory()) // TODO change to PERIOD
if err == nil { // integer only, treated as seconds
p.logger.Warn("The value for the duration period of the updater does not have a time unit, you might want to set it to \"%ds\" instead of \"%d\"", n, n)
return time.Duration(n) * time.Second, nil
}
return p.envParams.GetDuration("DELAY", setters...)
}
func (p *params) GetExeDir() (dir string, err error) {
return p.envParams.GetExeDir()
}
func (p *params) GetHTTPTimeout() (duration time.Duration, err error) {
return p.envParams.GetHTTPTimeout(libparams.Default("10s"))
}
package params
import (
"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 {
// 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
GetVcsRef() string
}
type reader struct {
envParams libparams.EnvParams
verifier verification.Verifier
readFile func(filename string) ([]byte, error)
}
func NewReader(logger logging.Logger) Reader {
return &reader{
envParams: libparams.NewEnvParams(),
verifier: verification.NewVerifier(),
readFile: ioutil.ReadFile,
}
}
// GetDataDir obtains the data directory from the environment
// variable DATADIR
func (r *reader) GetDataDir(currentDir string) (string, error) {
return r.envParams.GetEnv("DATADIR", libparams.Default(currentDir+"/data"))
}
func (r *reader) GetListeningPort() (listeningPort, warning string, err error) {
return r.envParams.GetListeningPort()
}
func (r *reader) GetLoggerConfig() (encoding logging.Encoding, level logging.Level, nodeID int, err error) {
return r.envParams.GetLoggerConfig()
}
func (r *reader) GetGotifyURL() (url *url.URL, err error) {
return r.envParams.GetGotifyURL()
}
func (r *reader) GetGotifyToken() (token string, err error) {
return r.envParams.GetGotifyToken()
}
func (r *reader) GetRootURL() (rootURL string, err error) {
return r.envParams.GetRootURL()
}
func (r *reader) GetPeriod() (period time.Duration, warnings []string, err error) {
// Backward compatibility
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
}
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) {
return r.envParams.GetExeDir()
}
func (r *reader) GetHTTPTimeout() (duration time.Duration, err error) {
return r.envParams.GetHTTPTimeout(libparams.Default("10s"))
}
func (r *reader) GetBackupPeriod() (duration time.Duration, err error) {
s, err := r.envParams.GetEnv("BACKUP_PERIOD", libparams.Default("0"))
if err != nil {
return 0, err
}
return time.ParseDuration(s)
}
func (r *reader) GetBackupDirectory() (directory string, err error) {
return r.envParams.GetEnv("BACKUP_DIRECTORY", libparams.Default("./data"))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,241 @@
package settings
import (
"encoding/json"
"fmt"
"net"
"net/http"
"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"
)
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 {
Result string `json:"result"`
Data []struct {
Editable string `json:"editable"`
Type string `json:"type"`
Record string `json:"record"`
Value string `json:"value"`
} `json:"data"`
}
dreamhostReponse struct {
Result string `json:"result"`
Data string `json:"data"`
}
)
func makeDreamhostDefaultValues(key string) (values url.Values) { //nolint:unparam
values.Set("key", key)
values.Set("unique_id", uuid.New().String())
values.Set("format", "json")
return values
}
func listDreamhostRecords(client network.Client, key string) (records dreamHostRecords, err error) {
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-list_records")
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return records, err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return records, err
} else if status != http.StatusOK {
return records, fmt.Errorf("HTTP status %d", status)
}
if err := json.Unmarshal(content, &records); err != nil {
return records, err
} else if records.Result != success {
return records, fmt.Errorf(records.Result)
}
return records, nil
}
func removeDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-remove_record")
values.Set("record", domain)
values.Set("type", recordType)
values.Set("value", ip.String())
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != success { // this should not happen
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
return nil
}
func addDreamhostRecord(client network.Client, key, domain string, ip net.IP) error { //nolint:dupl
recordType := A
if ip.To4() == nil {
recordType = AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.dreamhost.com",
}
values := makeDreamhostDefaultValues(key)
values.Set("cmd", "dns-add_record")
values.Set("record", domain)
values.Set("type", recordType)
values.Set("value", ip.String())
u.RawQuery = values.Encode()
r, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
r.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != success {
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
return nil
}

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

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

View File

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

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,51 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/golibs/network"
)
func updateDDNSS(client network.Client, domain, host, username, password string, ip net.IP) error {
var hostname string
if host == "@" {
hostname = strings.ToLower(domain)
} else {
hostname = strings.ToLower(host + "." + domain)
}
url := fmt.Sprintf("http://www.ddnss.de/upd.php?user=%s&pwd=%s&host=%s", username, password, hostname)
if ip != nil {
if ip.To4() == nil { // ipv6
url += fmt.Sprintf("&ip6=%s", ip)
} else {
url += fmt.Sprintf("&ip=%s", ip)
}
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
}
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", hostname)
case strings.Contains(s, "Updated 1 hostname"):
return nil
default:
return fmt.Errorf("unknown response received from ddnss.de: %s", s)
}
}

View File

@@ -1,99 +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")
}
body := bytes.NewBufferString(url.Values{
"login_token": []string{token},
"format": []string{"json"},
"domain": []string{domain},
"length": []string{"200"},
"sub_domain": []string{host},
"record_type": []string{"A"},
}.Encode())
req, err := http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.List", body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err := client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var recordResp struct {
Records []struct {
ID string `json:"id"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Line string `json:"line"`
} `json:"records"`
}
if err := json.Unmarshal(content, &recordResp); err != nil {
return err
}
var recordID, recordLine string
for _, record := range recordResp.Records {
if record.Type == "A" && record.Name == host {
receivedIP := net.ParseIP(record.Value)
if ip.Equal(receivedIP) {
return nil
}
recordID = record.ID
recordLine = record.Line
break
}
}
if len(recordID) == 0 {
return fmt.Errorf("record not found")
}
body = bytes.NewBufferString(url.Values{
"login_token": []string{token},
"format": []string{"json"},
"domain": []string{domain},
"record_id": []string{recordID},
"value": []string{ip.String()},
"record_line": []string{recordLine},
"sub_domain": []string{host},
}.Encode())
req, err = http.NewRequest(http.MethodPost, "https://dnsapi.cn/Record.Ddns", body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
status, content, err = client.DoHTTPRequest(req)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var ddnsResp struct {
Record struct {
ID int64 `json:"id"`
Value string `json:"value"`
Name string `json:"name"`
} `json:"record"`
}
if err := json.Unmarshal(content, &ddnsResp); err != nil {
return err
}
receivedIP := net.ParseIP(ddnsResp.Record.Value)
if !ip.Equal(receivedIP) {
return fmt.Errorf("ip not set")
}
return nil
}

View File

@@ -1,101 +0,0 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
)
func updateDreamhost(client network.Client, domain, key, domainName string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
type dreamhostReponse struct {
Result string `json:"result"`
Data string `json:"data"`
}
// List records
url := constants.DreamhostURL + "/?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records"
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhList struct {
Result string `json:"result"`
Data []struct {
Editable string `json:"editable"`
Type string `json:"type"`
Record string `json:"record"`
Value string `json:"value"`
} `json:"data"`
}
if err := json.Unmarshal(content, &dhList); err != nil {
return err
} else if dhList.Result != "success" {
return fmt.Errorf(dhList.Result)
}
var oldIP net.IP
for _, data := range dhList.Data {
if data.Type == "A" && data.Record == domainName {
if data.Editable == "0" {
return fmt.Errorf("record data is not editable")
}
oldIP = net.ParseIP(data.Value)
if ip.Equal(oldIP) { // success, nothing to change
return nil
}
break
}
}
if oldIP != nil { // Found editable record with a different IP address, so remove it
url = constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + strings.ToLower(domain) + "&type=A&value=" + oldIP.String()
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
if err := json.Unmarshal(content, &dhResponse); err != nil {
return err
} else if dhResponse.Result != "success" { // this should not happen
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
}
// Create the right record
url = constants.DreamhostURL + "?key=" + key + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + strings.ToLower(domain) + "&type=A&value=" + ip.String()
r, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
status, content, err = client.DoHTTPRequest(r)
if err != nil {
return err
} else if status != http.StatusOK {
return fmt.Errorf("HTTP status %d", status)
}
var dhResponse dreamhostReponse
err = json.Unmarshal(content, &dhResponse)
if err != nil {
return err
} else if dhResponse.Result != "success" {
return fmt.Errorf("%s - %s", dhResponse.Result, dhResponse.Data)
}
return nil
}

View File

@@ -1,51 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
func updateDuckDNS(client network.Client, domain, token string, ip net.IP) (newIP net.IP, err error) {
url := constants.DuckDNSURL + "?domains=" + strings.ToLower(domain) + "&token=" + token + "&verbose=true"
if ip != nil {
url += "&ip=" + ip.String()
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
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,50 +0,0 @@
package update
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/network"
libnetwork "github.com/qdm12/golibs/network"
)
func updateGoDaddy(client libnetwork.Client, host, domain, key, secret string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("IP address was not given to updater")
}
type goDaddyPutBody struct {
Data string `json:"data"` // IP address to update to
}
URL := constants.GoDaddyURL + "/" + strings.ToLower(domain) + "/records/A/" + strings.ToLower(host)
r, err := network.BuildHTTPPut(
URL,
[]goDaddyPutBody{
goDaddyPutBody{
ip.String(),
},
},
)
if err != nil {
return err
}
r.Header.Set("Authorization", "sso-key "+key+":"+secret)
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return err
} 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,66 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/golibs/network"
)
func updateInfomaniak(client network.Client, domain, host, username, password string, ip net.IP) (newIP net.IP, err error) {
var hostname string
if host == "@" {
hostname = strings.ToLower(domain)
} else {
hostname = strings.ToLower(host + "." + domain)
}
url := fmt.Sprintf("https://%s:%s@infomaniak.com/nic/update?hostname=%s", username, password, hostname)
if ip != nil {
url += fmt.Sprintf("&myip=%s", ip)
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
}
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,49 +0,0 @@
package update
import (
"encoding/xml"
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
)
func updateNamecheap(client network.Client, host, domain, password string, ip net.IP) (newIP net.IP, err error) {
url := constants.NamecheapURL + "?host=" + strings.ToLower(host) + "&domain=" + strings.ToLower(domain) + "&password=" + password
if ip != nil {
url += "&ip=" + ip.String()
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
status, content, err := client.DoHTTPRequest(r)
if err != nil {
return nil, err
} else if status != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", status)
}
var parsedXML struct {
Errors struct {
Error string `xml:"Err1"`
} `xml:"errors"`
IP string `xml:"IP"`
}
err = xml.Unmarshal(content, &parsedXML)
if err != nil {
return nil, err
} else if parsedXML.Errors.Error != "" {
return nil, fmt.Errorf(parsedXML.Errors.Error)
}
newIP = net.ParseIP(parsedXML.IP)
if newIP == nil {
return nil, fmt.Errorf("IP address received %q is malformed", parsedXML.IP)
}
if ip != nil && !ip.Equal(newIP) {
return nil, fmt.Errorf("new IP address %s is not %s", newIP.String(), ip.String())
}
return newIP, nil
}

View File

@@ -1,61 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/golibs/network"
"github.com/qdm12/golibs/verification"
)
func updateNoIP(client network.Client, hostname, username, password string, ip net.IP) (newIP net.IP, err error) {
url := constants.NoIPURL + "?hostname=" + strings.ToLower(hostname)
if ip != nil {
url += "&myip=" + ip.String()
}
url = strings.Replace(url, "https://", "https://"+username+":"+password+"@", 1)
r, err := http.NewRequest(http.MethodGet, url, 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

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

View File

@@ -1,88 +0,0 @@
package update
import (
"fmt"
"net"
"net/http"
"testing"
"github.com/qdm12/ddns-updater/internal/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/golibs/network/mocks"
"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()
client := &mocks.Client{}
if len(tc.mockURL) != 0 {
client.On("GetContent", tc.mockURL).Return(
tc.mockContent, http.StatusOK, nil).Once()
}
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))
client.AssertExpectations(t)
})
}
}

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>