├── .devcontainer
├── .dockerignore
├── Dockerfile
├── README.md
├── devcontainer.json
└── docker-compose.yml
├── .dockerignore
├── .github
├── CODEOWNERS
├── CONTRIBUTING.md
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug.md
│ ├── feature_request.md
│ └── help.md
├── dependabot.yml
├── labels.yml
└── workflows
│ ├── build-skip.yml
│ ├── build.yml
│ ├── configs
│ ├── .goreleaser.yaml
│ └── mlc-config.json
│ ├── labels.yml
│ ├── markdown-skip.yml
│ └── markdown.yml
├── .gitignore
├── .golangci.yml
├── .markdownlint.json
├── Dockerfile
├── LICENSE
├── README.md
├── cmd
└── ddns-updater
│ └── main.go
├── config.json
├── docker-compose.yml
├── docs
├── aliyun.md
├── allinkl.md
├── changeip.md
├── cloudflare.md
├── custom.md
├── dd24.md
├── ddnss.de.md
├── desec.md
├── digitalocean.md
├── dnsomatic.md
├── dnspod.md
├── domeneshop.md
├── dondominio.md
├── dreamhost.md
├── duckdns.md
├── dyndns.md
├── dynu.md
├── dynv6.md
├── easydns.md
├── example.md
├── freedns.md
├── gandi.md
├── gcp.md
├── godaddy.md
├── goip.md
├── he.net.md
├── hetzner.md
├── infomaniak.md
├── inwx.md
├── ionos.md
├── linode.md
├── loopia.md
├── luadns.md
├── myaddr.md
├── name.com.md
├── namecheap.md
├── namesilo.md
├── netcup.md
├── njalla.md
├── noip.md
├── nowdns.md
├── opendns.md
├── ovh.md
├── porkbun.md
├── route53.md
├── selfhost.de.md
├── servercow.md
├── spdyn.md
├── strato.md
├── variomedia.md
├── vultr.md
└── zoneedit.md
├── go.mod
├── go.sum
├── internal
├── backup
│ ├── interfaces.go
│ ├── service.go
│ └── zip.go
├── config
│ ├── backup.go
│ ├── client.go
│ ├── health.go
│ ├── helpers.go
│ ├── logger.go
│ ├── paths.go
│ ├── paths_test.go
│ ├── pubip.go
│ ├── resolver.go
│ ├── retrocompat.go
│ ├── server.go
│ ├── settings.go
│ ├── settings_test.go
│ ├── shoutrrr.go
│ └── update.go
├── constants
│ └── status.go
├── data
│ ├── data.go
│ ├── interfaces.go
│ ├── memory.go
│ └── persistence.go
├── health
│ ├── check.go
│ ├── client.go
│ ├── handler.go
│ ├── httpcheck.go
│ ├── interfaces.go
│ └── server.go
├── healthchecksio
│ └── healthchecksio.go
├── models
│ ├── alias.go
│ ├── build.go
│ ├── history.go
│ ├── history_test.go
│ ├── html.go
│ └── ipmethod.go
├── noop
│ └── service.go
├── params
│ ├── json.go
│ ├── json_test.go
│ ├── reader.go
│ ├── retro.go
│ └── retro_test.go
├── persistence
│ └── json
│ │ ├── database.go
│ │ ├── models.go
│ │ └── queries.go
├── provider
│ ├── constants
│ │ ├── dyndns.go
│ │ ├── providers.go
│ │ └── recordtypes.go
│ ├── errors
│ │ ├── update.go
│ │ └── validation.go
│ ├── headers
│ │ └── headers.go
│ ├── provider.go
│ ├── providers
│ │ ├── aliyun
│ │ │ ├── auth.go
│ │ │ ├── common.go
│ │ │ ├── create.go
│ │ │ ├── getrecord.go
│ │ │ ├── provider.go
│ │ │ └── update.go
│ │ ├── allinkl
│ │ │ └── provider.go
│ │ ├── changeip
│ │ │ └── provider.go
│ │ ├── cloudflare
│ │ │ └── provider.go
│ │ ├── custom
│ │ │ └── provider.go
│ │ ├── dd24
│ │ │ └── provider.go
│ │ ├── ddnss
│ │ │ └── provider.go
│ │ ├── desec
│ │ │ └── provider.go
│ │ ├── digitalocean
│ │ │ └── provider.go
│ │ ├── dnsomatic
│ │ │ ├── provider.go
│ │ │ └── provider_test.go
│ │ ├── dnspod
│ │ │ └── provider.go
│ │ ├── domeneshop
│ │ │ └── provider.go
│ │ ├── dondominio
│ │ │ └── provider.go
│ │ ├── dreamhost
│ │ │ ├── headers.go
│ │ │ └── provider.go
│ │ ├── duckdns
│ │ │ └── provider.go
│ │ ├── dyn
│ │ │ └── provider.go
│ │ ├── dynu
│ │ │ └── provider.go
│ │ ├── dynv6
│ │ │ └── provider.go
│ │ ├── easydns
│ │ │ └── provider.go
│ │ ├── example
│ │ │ └── provider.go
│ │ ├── freedns
│ │ │ └── provider.go
│ │ ├── gandi
│ │ │ └── provider.go
│ │ ├── gcp
│ │ │ ├── api.go
│ │ │ ├── error.go
│ │ │ ├── oauth2.go
│ │ │ ├── provider.go
│ │ │ └── update.go
│ │ ├── godaddy
│ │ │ └── provider.go
│ │ ├── goip
│ │ │ └── provider.go
│ │ ├── he
│ │ │ └── provider.go
│ │ ├── hetzner
│ │ │ ├── common.go
│ │ │ ├── create.go
│ │ │ ├── getrecord.go
│ │ │ ├── provider.go
│ │ │ └── update.go
│ │ ├── infomaniak
│ │ │ └── provider.go
│ │ ├── inwx
│ │ │ └── provider.go
│ │ ├── ionos
│ │ │ ├── api.go
│ │ │ ├── create.go
│ │ │ ├── get.go
│ │ │ ├── provider.go
│ │ │ └── update.go
│ │ ├── linode
│ │ │ └── provider.go
│ │ ├── loopia
│ │ │ └── provider.go
│ │ ├── luadns
│ │ │ └── provider.go
│ │ ├── myaddr
│ │ │ └── provider.go
│ │ ├── namecheap
│ │ │ └── provider.go
│ │ ├── namecom
│ │ │ ├── createrecord.go
│ │ │ ├── getrecord.go
│ │ │ ├── headers.go
│ │ │ ├── provider.go
│ │ │ ├── response.go
│ │ │ └── updaterecord.go
│ │ ├── namesilo
│ │ │ └── provider.go
│ │ ├── netcup
│ │ │ ├── info.go
│ │ │ ├── json.go
│ │ │ ├── login.go
│ │ │ ├── models.go
│ │ │ ├── provider.go
│ │ │ └── update.go
│ │ ├── njalla
│ │ │ └── provider.go
│ │ ├── noip
│ │ │ └── provider.go
│ │ ├── nowdns
│ │ │ └── provider.go
│ │ ├── opendns
│ │ │ └── provider.go
│ │ ├── ovh
│ │ │ ├── createrecord.go
│ │ │ ├── endpoints.go
│ │ │ ├── errors.go
│ │ │ ├── getrecords.go
│ │ │ ├── headers.go
│ │ │ ├── provider.go
│ │ │ ├── refresh.go
│ │ │ ├── time.go
│ │ │ └── updaterecord.go
│ │ ├── porkbun
│ │ │ ├── api.go
│ │ │ ├── error.go
│ │ │ └── provider.go
│ │ ├── route53
│ │ │ ├── api.go
│ │ │ ├── api_test.go
│ │ │ ├── provider.go
│ │ │ ├── signer.go
│ │ │ └── signer_test.go
│ │ ├── selfhostde
│ │ │ └── provider.go
│ │ ├── servercow
│ │ │ └── provider.go
│ │ ├── spdyn
│ │ │ └── provider.go
│ │ ├── strato
│ │ │ └── provider.go
│ │ ├── variomedia
│ │ │ └── provider.go
│ │ ├── vultr
│ │ │ ├── createrecord.go
│ │ │ ├── getrecord.go
│ │ │ ├── provider.go
│ │ │ └── updaterecord.go
│ │ └── zoneedit
│ │ │ └── provider.go
│ └── utils
│ │ ├── body.go
│ │ ├── domain.go
│ │ ├── domain_test.go
│ │ ├── provider.go
│ │ └── singleline.go
├── records
│ ├── html.go
│ └── records.go
├── resolver
│ ├── resolver.go
│ └── settings.go
├── server
│ ├── error.go
│ ├── handler.go
│ ├── index.go
│ ├── interfaces.go
│ ├── server.go
│ ├── ui
│ │ ├── index.html
│ │ └── static
│ │ │ ├── favicon.ico
│ │ │ ├── favicon.svg
│ │ │ └── styles.css
│ └── update.go
├── shoutrrr
│ ├── interfaces.go
│ ├── settings.go
│ ├── shoutrrr.go
│ └── shoutrrr_test.go
├── system
│ ├── umask_unix.go
│ └── umask_windows.go
└── update
│ ├── getip.go
│ ├── helpers.go
│ ├── interfaces.go
│ ├── ipv6.go
│ ├── ipv6_test.go
│ ├── logclient.go
│ ├── logclient_test.go
│ ├── mock_update
│ └── logclient.go
│ ├── service.go
│ └── update.go
├── k8s
├── README.md
├── base
│ ├── deployment.yaml
│ ├── kustomization.yaml
│ ├── secret-config.yaml
│ └── service.yaml
└── overlay
│ ├── with-ingress-tls-cert-manager
│ ├── ingress.yaml
│ └── kustomization.yaml
│ └── with-ingress
│ ├── ingress.yaml
│ └── kustomization.yaml
├── pkg
├── ipextract
│ ├── ipextract.go
│ └── ipextract_test.go
└── publicip
│ ├── dns
│ ├── client.go
│ ├── dns.go
│ ├── dns_test.go
│ ├── fetch.go
│ ├── fetch_test.go
│ ├── integration_test.go
│ ├── ip.go
│ ├── mock_dns
│ │ └── client.go
│ ├── options.go
│ ├── options_test.go
│ ├── providers.go
│ └── providers_test.go
│ ├── http
│ ├── fetch.go
│ ├── fetch_test.go
│ ├── http.go
│ ├── http_test.go
│ ├── integration_test.go
│ ├── ip.go
│ ├── ip_test.go
│ ├── options.go
│ ├── options_test.go
│ ├── providers.go
│ ├── providers_test.go
│ └── roudtrip_test.go
│ ├── info
│ ├── countries.go
│ ├── errors.go
│ ├── http.go
│ ├── info.go
│ ├── ip2location.go
│ ├── ipinfo.go
│ ├── options.go
│ ├── provider.go
│ ├── rand.go
│ ├── result.go
│ └── settings.go
│ ├── ipversion
│ └── ipversion.go
│ ├── publicip.go
│ ├── settings.go
│ └── subfetcher.go
└── readme
├── ddnsgopher.svg
├── duckdns.png
├── godaddy.png
├── godaddy1.gif
├── godaddy2.gif
├── godaddy3.gif
├── godaddydnsmanagement.png
├── name.svg
├── namecheap.png
├── namecheap1.png
├── namecheap2.png
├── namecheap3.png
├── namecheap4.png
├── namesilo.jpg
├── namesilo1.jpg
├── namesilo2.jpg
├── namesilo3.jpg
├── shoutrrr_version_test.go
├── webui-desktop.gif
└── webui-mobile.png
/.devcontainer/.dockerignore:
--------------------------------------------------------------------------------
1 | .dockerignore
2 | devcontainer.json
3 | docker-compose.yml
4 | Dockerfile
5 | README.md
6 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM qmcgaw/godevcontainer:v0.20-alpine
2 |
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | vscode:
3 | build: .
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .devcontainer
2 | .git
3 | .github
4 | .vscode
5 | docs
6 | readme
7 | !readme/*.go
8 | .gitignore
9 | config.json
10 | docker-compose.yml
11 | LICENSE
12 | ui/favicon.svg
13 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | @qdm12
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [qdm12]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug
3 | about: Report a bug
4 | title: 'Bug: FILL THIS TEXT OR ISSUE WILL BE CLOSED'
5 | labels:
6 |
7 | ---
8 |
9 |
16 |
17 | **TLDR**: *Describe your issue in a one liner here*
18 |
19 | 1. Is this urgent: Yes/No
20 | 2. DNS provider(s) you use: Answer here
21 | 3. Program version:
22 |
23 |
24 |
25 | `Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c)`
26 |
27 | 4. What are you using to run the container: docker-compose
28 | 5. Extra information (optional)
29 |
30 | Logs:
31 |
32 | ```log
33 |
34 | ```
35 |
36 | Configuration file (**remove your credentials!**):
37 |
38 | ```json
39 |
40 | ```
41 |
42 | Host OS:
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest a feature to add to this project
4 | title: 'Feature request: FILL THIS TEXT OR ISSUE WILL BE CLOSED'
5 | labels:
6 |
7 | ---
8 |
9 | 1. What's the feature?
10 |
11 | 2. Extra information?
12 |
13 |
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/help.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Help
3 | about: Ask for help
4 | title: 'Help: FILL THIS TEXT OR ISSUE WILL BE CLOSED'
5 | labels:
6 |
7 | ---
8 |
9 |
16 |
17 | **TLDR**: *Describe your issue in a one liner here*
18 |
19 | 1. Is this urgent: Yes/No
20 | 2. DNS provider(s) you use: Answer here
21 | 3. Program version:
22 |
23 |
24 |
25 | `Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c)`
26 |
27 | 4. What are you using to run the container: docker-compose
28 | 5. Extra information (optional)
29 |
30 | Logs:
31 |
32 | ```log
33 |
34 | ```
35 |
36 | Configuration file (**remove your credentials!**):
37 |
38 | ```json
39 |
40 | ```
41 |
42 | Host OS:
43 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 | - package-ecosystem: docker
9 | directory: /
10 | schedule:
11 | interval: "daily"
12 | - package-ecosystem: gomod
13 | directory: /
14 | schedule:
15 | interval: "daily"
16 |
--------------------------------------------------------------------------------
/.github/labels.yml:
--------------------------------------------------------------------------------
1 | - name: "Status: 🗯️ Waiting for feedback"
2 | color: "f7d692"
3 | - name: "Status: 🔴 Blocked"
4 | color: "f7d692"
5 | description: "Blocked by another issue or pull request"
6 | - name: "Status: 🔒 After next release"
7 | color: "f7d692"
8 | description: "Will be done after the next release"
9 |
10 | - name: "Closed: ⚰️ Inactive"
11 | color: "959a9c"
12 | description: "No answer was received for weeks"
13 | - name: "Closed: 👥 Duplicate"
14 | color: "959a9c"
15 | description: "Issue duplicates an existing issue"
16 | - name: "Closed: 🗑️ Bad issue"
17 | color: "959a9c"
18 |
19 | - name: "Priority: 🚨 Urgent"
20 | color: "03adfc"
21 | - name: "Priority: 💤 Low priority"
22 | color: "03adfc"
23 |
24 | - name: "Complexity: ☣️ Hard to do"
25 | color: "ff9efc"
26 | - name: "Complexity: 🟩 Easy to do"
27 | color: "ff9efc"
28 |
29 | - name: "Category: Config problem 📝"
30 | color: "ffc7ea"
31 | - name: "Category: Healthcheck 🩺"
32 | color: "ffc7ea"
33 | - name: "Category: Documentation ✒️"
34 | description: "A problem with the readme or in the docs/ directory"
35 | color: "ffc7ea"
36 | - name: "Category: Maintenance ⛓️"
37 | description: "Anything related to code or other maintenance"
38 | color: "ffc7ea"
39 | - name: "Category: Good idea 🎯"
40 | description: "This is a good idea, judged by the maintainers"
41 | color: "ffc7ea"
42 | - name: "Category: Motivated! 🙌"
43 | description: "Your pumpness makes me pumped! The issue or PR shows great motivation!"
44 | color: "ffc7ea"
45 | - name: "Category: Foolproof settings 👼"
46 | color: "ffc7ea"
47 | - name: "Category: Label missing ❗"
48 | color: "ffc7ea"
49 | - name: "Category: Provider update ♻️"
50 | color: "ffc7ea"
51 | - name: "Category: Shoutrrr 📢"
52 | color: "ffc7ea"
53 | - name: "Category: IP fetching 📥"
54 | color: "ffc7ea"
55 | - name: "Category: Database 🗃️"
56 | color: "ffc7ea"
57 | - name: "Category: New provider 🆕"
58 | color: "ffc7ea"
59 | - name: "Category: Web UI 🖱️"
60 | color: "ffc7ea"
61 | - name: "Category: Wildcard 🃏"
62 | color: "ffc7ea"
63 |
--------------------------------------------------------------------------------
/.github/workflows/build-skip.yml:
--------------------------------------------------------------------------------
1 | name: No trigger file paths
2 | on:
3 | push:
4 | branches:
5 | - master
6 | paths-ignore:
7 | - .github/workflows/build.yml
8 | - cmd/**
9 | - internal/**
10 | - pkg/**
11 | - .dockerignore
12 | - .golangci.yml
13 | - Dockerfile
14 | - go.mod
15 | - go.sum
16 | pull_request:
17 | paths-ignore:
18 | - .github/workflows/build.yml
19 | - cmd/**
20 | - internal/**
21 | - pkg/**
22 | - .dockerignore
23 | - .golangci.yml
24 | - Dockerfile
25 | - go.mod
26 | - go.sum
27 |
28 | jobs:
29 | verify:
30 | runs-on: ubuntu-latest
31 | permissions:
32 | actions: read
33 | steps:
34 | - name: No trigger path triggered for required verify workflow.
35 | run: exit 0
36 |
--------------------------------------------------------------------------------
/.github/workflows/configs/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | before:
2 | hooks:
3 | - go mod download
4 | builds:
5 | - main: ./cmd/ddns-updater/main.go
6 | flags:
7 | - -trimpath
8 | env:
9 | - CGO_ENABLED=0
10 | targets:
11 | # See https://goreleaser.com/customization/build/
12 | - linux_amd64
13 | - linux_386
14 | - linux_arm64
15 | - linux_arm_7
16 | - linux_arm_6
17 | - linux_arm_5
18 | - darwin_amd64
19 | - darwin_arm64
20 | - windows_amd64
21 | - windows_386
22 | - windows_arm64
23 | archives:
24 | - format: binary
25 | checksum:
26 | name_template: "checksums.txt"
27 | snapshot:
28 | name_template: "{{ .Tag }}-next"
29 | changelog:
30 | sort: asc
31 |
--------------------------------------------------------------------------------
/.github/workflows/configs/mlc-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePatterns": [
3 | {
4 | "pattern": "^http://localhost"
5 | },
6 | {
7 | "pattern": "^https://api6.ipify.org$"
8 | },
9 | {
10 | "pattern": "^http://ip1.dynupdate6.no-ip.com$"
11 | },
12 | {
13 | "pattern": "^https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns$"
14 | },
15 | {
16 | "pattern": "^https://www.godaddy.com"
17 | },
18 | {
19 | "pattern": "^https://www.namecheap.com"
20 | },
21 | {
22 | "pattern": "https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/"
23 | },
24 | {
25 | "pattern": "https://(ip|ipv|v)6.+"
26 | },
27 | {
28 | "pattern": "https://github.com/qdm12/ddns-updater/pkgs/container/ddns-updater"
29 | },
30 | {
31 | "pattern": "^https://www.duckdns.org/$"
32 | },
33 | {
34 | "pattern": "^https://my.vultr.com/settings/#settingsapi$"
35 | },
36 | {
37 | "pattern": "^https://www.namesilo.com"
38 | }
39 | ],
40 | "timeout": "20s",
41 | "retryOn429": false,
42 | "fallbackRetryDelay": "30s",
43 | "aliveStatusCodes": [
44 | 200,
45 | 206
46 | ]
47 | }
--------------------------------------------------------------------------------
/.github/workflows/labels.yml:
--------------------------------------------------------------------------------
1 | name: labels
2 | on:
3 | push:
4 | branches: [master]
5 | paths:
6 | - .github/labels.yml
7 | - .github/workflows/labels.yml
8 | jobs:
9 | labeler:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v4
14 | - name: Labeler
15 | if: success()
16 | uses: crazy-max/ghaction-github-labeler@v5
17 | with:
18 | github-token: ${{ secrets.GITHUB_TOKEN }}
19 |
--------------------------------------------------------------------------------
/.github/workflows/markdown-skip.yml:
--------------------------------------------------------------------------------
1 | name: Markdown
2 | on:
3 | push:
4 | branches:
5 | - master
6 | paths-ignore:
7 | - "**.md"
8 | - .github/workflows/markdown.yml
9 | pull_request:
10 | paths-ignore:
11 | - "**.md"
12 | - .github/workflows/markdown.yml
13 |
14 | jobs:
15 | markdown:
16 | runs-on: ubuntu-latest
17 | permissions:
18 | actions: read
19 | steps:
20 | - name: No trigger path triggered for required markdown workflow.
21 | run: exit 0
22 |
--------------------------------------------------------------------------------
/.github/workflows/markdown.yml:
--------------------------------------------------------------------------------
1 | name: Markdown
2 | on:
3 | push:
4 | branches:
5 | - master
6 | paths:
7 | - "**.md"
8 | - .github/workflows/markdown.yml
9 | pull_request:
10 | paths:
11 | - "**.md"
12 | - .github/workflows/markdown.yml
13 |
14 | jobs:
15 | markdown:
16 | runs-on: ubuntu-latest
17 | permissions:
18 | actions: read
19 | contents: read
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - uses: DavidAnson/markdownlint-cli2-action@v18
24 | with:
25 | globs: "**.md"
26 | config: .markdownlint.json
27 |
28 | - uses: reviewdog/action-misspell@v1
29 | with:
30 | locale: "US"
31 | level: error
32 | pattern: |
33 | *.md
34 |
35 | - uses: gaurav-nelson/github-action-markdown-link-check@v1
36 | with:
37 | use-quiet-mode: yes
38 | config-file: .github/workflows/configs/mlc-config.json
39 |
40 | - uses: peter-evans/dockerhub-description@v4
41 | if: github.repository == 'qdm12/ddns-updater' && github.event_name == 'push'
42 | with:
43 | username: qmcgaw
44 | password: ${{ secrets.DOCKERHUB_PASSWORD }}
45 | repository: qmcgaw/ddns-updater
46 | short-description: Container to update DNS records periodically with WebUI for many DNS providers
47 | readme-filepath: README.md
48 | enable-url-completion: true
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /data
--------------------------------------------------------------------------------
/.markdownlint.json:
--------------------------------------------------------------------------------
1 | {
2 | "MD013": false,
3 | "MD033": {
4 | "allowed_elements": [
5 | "img"
6 | ]
7 | }
8 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Quentin McGaw
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "settings": [
3 | {
4 | "provider": "namecheap",
5 | "domain": "example.com",
6 | "password": "e5322165c1d74692bfa6d807100c0310"
7 | },
8 | {
9 | "provider": "duckdns",
10 | "domain": "example.duckdns.org",
11 | "token": "00000000-0000-0000-0000-000000000000"
12 | },
13 | {
14 | "provider": "godaddy",
15 | "domain": "subdomain.example.org",
16 | "key": "aaaaaaaaaaaaaaaa",
17 | "secret": "aaaaaaaaaaaaaaaa"
18 | },
19 | {
20 | "provider": "dreamhost",
21 | "domain": "example.info",
22 | "key": "aaaaaaaaaaaaaaaa"
23 | }
24 | ]
25 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 | services:
3 | ddns-updater:
4 | image: qmcgaw/ddns-updater
5 | container_name: ddns-updater
6 | network_mode: bridge
7 | ports:
8 | - 8000:8000/tcp
9 | volumes:
10 | - ./data:/updater/data
11 | environment:
12 | - CONFIG=
13 | - PERIOD=5m
14 | - UPDATE_COOLDOWN_PERIOD=5m
15 | - PUBLICIP_FETCHERS=all
16 | - PUBLICIP_HTTP_PROVIDERS=all
17 | - PUBLICIPV4_HTTP_PROVIDERS=all
18 | - PUBLICIPV6_HTTP_PROVIDERS=all
19 | - PUBLICIP_DNS_PROVIDERS=all
20 | - PUBLICIP_DNS_TIMEOUT=3s
21 | - HTTP_TIMEOUT=10s
22 |
23 | # Web UI
24 | - LISTENING_ADDRESS=:8000
25 | - ROOT_URL=/
26 |
27 | # Backup
28 | - BACKUP_PERIOD=0 # 0 to disable
29 | - BACKUP_DIRECTORY=/updater/data
30 |
31 | # Other
32 | - LOG_LEVEL=info
33 | - LOG_CALLER=hidden
34 | - SHOUTRRR_ADDRESSES=
35 | restart: always
36 |
--------------------------------------------------------------------------------
/docs/aliyun.md:
--------------------------------------------------------------------------------
1 | # Aliyun
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "aliyun",
12 | "domain": "domain.com",
13 | "access_key_id": "your access_key_id",
14 | "access_secret": "your access_secret",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"access_key_id"`
26 | - `"access_secret"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/allinkl.md:
--------------------------------------------------------------------------------
1 | # All-Inkl
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "allinkl",
12 | "domain": "sub.domain.com",
13 | "username": "dynXXXXXXX",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"` username (usually starts with dyn followed by numbers)
26 | - `"password"` password in plain text
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/changeip.md:
--------------------------------------------------------------------------------
1 | # ChangeIP
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "changeip",
12 | "domain": "sub.domain.com",
13 | "username": "dynXXXXXXX",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/cloudflare.md:
--------------------------------------------------------------------------------
1 | # Cloudflare
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "cloudflare",
12 | "zone_identifier": "some id",
13 | "domain": "domain.com",
14 | "ttl": 600,
15 | "token": "yourtoken",
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"zone_identifier"` is the Zone ID of your site, from the domain overview page written as *Zone ID*
26 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
27 | See [this issue comment for context](https://github.com/qdm12/ddns-updater/issues/243#issuecomment-928313949). This is left as is for compatibility.
28 | - `"ttl"` integer value for record TTL in seconds (specify 1 for automatic)
29 | - One of the following ([how to find API keys](https://developers.cloudflare.com/fundamentals/api/get-started/)):
30 | - Email `"email"` and Global API Key `"key"`
31 | - User service key `"user_service_key"`
32 | - API Token `"token"`, configured with DNS edit permissions for your DNS name's zone
33 |
34 | ### Optional parameters
35 |
36 | - `"proxied"` can be set to `true` to use the proxy services of Cloudflare
37 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
38 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
39 |
40 | Special thanks to @Starttoaster for helping out with the [documentation](https://gist.github.com/Starttoaster/07d568c2a99ad7631dd776688c988326) and testing.
41 |
--------------------------------------------------------------------------------
/docs/dd24.md:
--------------------------------------------------------------------------------
1 | # Domain Discount 24
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dd24",
12 | "domain": "domain.com",
13 | "password": "password",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
24 | - `"password"` is your password
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
--------------------------------------------------------------------------------
/docs/ddnss.de.md:
--------------------------------------------------------------------------------
1 | # DDNSS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "ddnss",
12 | "domain": "domain.com",
13 | "username": "user",
14 | "password": "password",
15 | "dual_stack": false,
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
26 | - `"username"`
27 | - `"password"`
28 |
29 | ### Optional parameters
30 |
31 | - `"dual_stack"` can be set to `true` **if you have turn on dual stack for your record** to update both IPv4 and IPv6 addresses. More precisely:
32 | - if it is `false`, the updates are done using the `ip` parameter and only one IP address can be set (ipv4 or ipv6, whichever is last sent).
33 | - if it is `true`, the updates are done using the `ip` and `ip6` parameters, for IPv4 and IPv6 respectively, and both can be set on the same record
34 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
35 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
36 |
37 | ## Domain setup
38 |
--------------------------------------------------------------------------------
/docs/desec.md:
--------------------------------------------------------------------------------
1 | # deSEC
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "desec",
12 | "domain": "sub.dedyn.io",
13 | "token": "token",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
24 | - `"token"` is your token that you can create [here](https://desec.io/tokens)
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | [desec.io/domains](https://desec.io/domains)
34 |
--------------------------------------------------------------------------------
/docs/digitalocean.md:
--------------------------------------------------------------------------------
1 | # Digital Ocean
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "digitalocean",
12 | "domain": "domain.com",
13 | "token": "yourtoken",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
24 | - `"token"` is your token that you can create [here](https://cloud.digitalocean.com/settings/applications)
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
--------------------------------------------------------------------------------
/docs/dnsomatic.md:
--------------------------------------------------------------------------------
1 | # DNS-O-Matic
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dnsomatic",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/dnspod.md:
--------------------------------------------------------------------------------
1 | # DNSPod
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dnspod",
12 | "domain": "domain.com",
13 | "token": "yourtoken",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
24 | - `"token"`
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
--------------------------------------------------------------------------------
/docs/domeneshop.md:
--------------------------------------------------------------------------------
1 | # Domeneshop.no
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "domeneshop",
12 | "domain": "domain.com,seconddomain.com",
13 | "token": "token",
14 | "secret": "secret",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`)
25 | - `"token"` See [api.domeneshop.no/docs/](https://api.domeneshop.no/docs/) for instructions on how to generate credentials.
26 | - `"secret"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
--------------------------------------------------------------------------------
/docs/dondominio.md:
--------------------------------------------------------------------------------
1 | # Don Dominio
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dondominio",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "key": "key",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
35 | See [dondominio.dev/en/dondns/docs/api/#before-start](https://dondominio.dev/en/dondns/docs/api/#before-start)
36 |
--------------------------------------------------------------------------------
/docs/dreamhost.md:
--------------------------------------------------------------------------------
1 | # Dreamhost
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dreamhost",
12 | "domain": "domain.com",
13 | "key": "key",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"`
24 | - `"key"`
25 |
26 | ### Optional parameters
27 |
28 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
29 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
30 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
31 |
32 | ## Domain setup
33 |
--------------------------------------------------------------------------------
/docs/duckdns.md:
--------------------------------------------------------------------------------
1 | # DuckDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "duckdns",
12 | "domain": "sub.duckdns.org",
13 | "token": "token",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. The [eTLD](https://developer.mozilla.org/en-US/docs/Glossary/eTLD) must be `duckdns.org`. For example:
24 | - for the root owner/host `@`, it would be `mydomain.duckdns.org`
25 | - for the owner/host `sub`, it would be `sub.mydomain.duckdns.org`
26 | - for multiple domains, it can be `sub1.mydomain.duckdns.org,sub2.mydomain.duckdns.org` BUT it cannot be `a.duckdns.org,b.duckdns.org`, since the effective domains would be `a.duckdns.org` and `b.duckdns.org`
27 | - `"token"`
28 |
29 | ### Optional parameters
30 |
31 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
32 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
33 |
34 | ## Domain setup
35 |
36 | [](https://www.duckdns.org/)
37 |
38 | *See the [duckdns website](https://www.duckdns.org/)*
39 |
--------------------------------------------------------------------------------
/docs/dyndns.md:
--------------------------------------------------------------------------------
1 | # DynDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dyn",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "client_key": "client_key",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
25 | - `"username"`
26 | - `"client_key"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/dynu.md:
--------------------------------------------------------------------------------
1 | # Dynu
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dynu",
12 | "domain": "domain.com",
13 | "group": "group",
14 | "username": "username",
15 | "password": "password",
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
26 | - `"username"`
27 | - `"password"` could be plain text or password in MD5 or SHA256 format (There's also an option for setting a password for IP Update only)
28 |
29 | ### Optional parameters
30 |
31 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
32 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
33 | - `"group"` specify the Group for which you want to set the IP (will update any domains and subdomains in the same group)
34 |
35 | ## Domain setup
36 |
--------------------------------------------------------------------------------
/docs/dynv6.md:
--------------------------------------------------------------------------------
1 | # DynV6
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "dynv6",
12 | "domain": "domain.com",
13 | "token": "token",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
24 | - `"token"` that you can obtain [here](https://dynv6.com/keys#token)
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
--------------------------------------------------------------------------------
/docs/easydns.md:
--------------------------------------------------------------------------------
1 | # EasyDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "easydns",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "token": "token",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
25 | - `"username"`
26 | - `"token"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/example.md:
--------------------------------------------------------------------------------
1 | # Example.com
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 |
8 |
9 | ```json
10 | {
11 | "settings": [
12 | {
13 | "provider": "example",
14 | "domain": "domain.com",
15 | "username": "username",
16 | "password": "password",
17 | "ip_version": "ipv4",
18 | "ipv6_suffix": ""
19 | }
20 | ]
21 | }
22 | ```
23 |
24 | ### Compulsory parameters
25 |
26 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
27 | - `"username"`
28 | - `"password"`
29 |
30 |
31 |
32 | ### Optional parameters
33 |
34 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
35 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
36 |
37 |
38 |
39 | ## Domain setup
40 |
41 |
--------------------------------------------------------------------------------
/docs/freedns.md:
--------------------------------------------------------------------------------
1 | # FreeDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "freedns",
12 | "domain": "sub.domain.com",
13 | "token": "token",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
24 | - `"token"` is the randomized update token you use to update your record
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | This integration uses FreeDNS's v2 dynamic dns interface, which is not shown by default when you select `Dynamic DNS` from the side menu.
34 | Instead you must go to [freedns.afraid.org/dynamic/v2/](https://freedns.afraid.org/dynamic/v2/) and enable dynamic DNS for the subdomains you wish and you will then see a url like `https://sync.afraid.org/u/token/` for each enabled subdomain.
35 |
--------------------------------------------------------------------------------
/docs/gandi.md:
--------------------------------------------------------------------------------
1 | # Gandi
2 |
3 | This provider uses Gandi v5 API
4 |
5 | ## Configuration
6 |
7 | ### Example
8 |
9 | ```json
10 | {
11 | "settings": [
12 | {
13 | "provider": "gandi",
14 | "domain": "domain.com",
15 | "personal_access_token": "token",
16 | "ttl": 3600,
17 | "ip_version": "ipv4",
18 | "ipv6_suffix": ""
19 | }
20 | ]
21 | }
22 | ```
23 |
24 | ### Compulsory parameters
25 |
26 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
27 | - `"personal_access_token"`
28 |
29 | ### Optional parameters
30 |
31 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
32 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
33 | - `"ttl"` default is `3600`
34 |
35 | ## Domain setup
36 |
37 | [Gandi Documentation Website](https://docs.gandi.net/en/rest_api/index.html)
38 |
--------------------------------------------------------------------------------
/docs/gcp.md:
--------------------------------------------------------------------------------
1 | # GCP
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "gcp",
12 | "project": "my-project-id",
13 | "zone": "zone",
14 | "credentials": {
15 | "type": "service_account",
16 | "project_id": "my-project-id",
17 | // ...
18 | },
19 | "domain": "domain.com",
20 | "ip_version": "ipv4",
21 | "ipv6_suffix": ""
22 | }
23 | ]
24 | }
25 | ```
26 |
27 | ### Compulsory parameters
28 |
29 | - `"project"` is the id of your Google Cloud project
30 | - `"zone"` is the zone, that your DNS record is located in
31 | - `"credentials"` is the JSON credentials for your Google Cloud project. This is usually downloaded as a JSON file, which you can copy paste the content as the value of the `"credentials"` key. More information on how to get it is available [here](https://cloud.google.com/docs/authentication/getting-started). Please ensure your service account has all necessary permissions to create/update/list/get DNS records within your project.
32 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
33 |
34 | ### Optional parameters
35 |
36 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
37 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
38 |
--------------------------------------------------------------------------------
/docs/goip.md:
--------------------------------------------------------------------------------
1 | # GoIP.de
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "goip",
12 | "domain": "sub.mydomain.goip.de",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "",
16 | "ipv6_suffix": ""
17 |
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"domain"` is the domain to update. For example, for the owner/host `sub`, it would be `sub.goip.de`. The [eTLD](https://developer.mozilla.org/en-US/docs/Glossary/eTLD) must be `goip.de` or `goip.it`.
26 | - `"domain"` is the domain to update. The [eTLD](https://developer.mozilla.org/en-US/docs/Glossary/eTLD) must be `goip.de` or `goip.it`. For example:
27 | - for the root owner/host `@`, it would be `mydomain.goip.de`
28 | - for the owner/host `sub`, it would be `sub.mydomain.goip.de`
29 | - for multiple domains, it can be `sub1.mydomain.goip.de,sub2.mydomain.goip.de` BUT it cannot be `a.goip.de,b.goip.de`, since the effective domains would be `a.goip.de` and `b.goip.de`
30 | - `"username"` is your goip.de username listed under "Routers"
31 | - `"password"` is your router account password
32 |
33 | ### Optional parameters
34 |
35 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4`.
36 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
37 |
--------------------------------------------------------------------------------
/docs/he.net.md:
--------------------------------------------------------------------------------
1 | # He.net
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "he",
12 | "domain": "domain.com",
13 | "password": "password",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard. (untested)
24 | - `"password"`
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
--------------------------------------------------------------------------------
/docs/hetzner.md:
--------------------------------------------------------------------------------
1 | # Hetzner
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "hetzner",
12 | "zone_identifier": "some id",
13 | "domain": "domain.com",
14 | "ttl": 600,
15 | "token": "yourtoken",
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"zone_identifier"` is the Zone ID of your site, from the domain overview page written as *Zone ID*
26 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
27 | - `"ttl"` optional integer value corresponding to a number of seconds
28 | - One of the following ([how to find API keys](https://docs.hetzner.com/cloud/api/getting-started/generating-api-token)):
29 | - API Token `"token"`, configured with DNS edit permissions for your DNS name's zone
30 |
31 | ### Optional parameters
32 |
33 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
34 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
35 |
--------------------------------------------------------------------------------
/docs/infomaniak.md:
--------------------------------------------------------------------------------
1 | # Infomaniak
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "infomaniak",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"` for dyndns (**not** your infomaniak admin username!)
26 | - `"password"` for dyndns (**not** your infomaniak admin password!)
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
35 | Follow [this guide](https://www.infomaniak.com/en/support/faq/2357/getting-started-guide-dyndns-with-an-infomaniak-domain) to set up your subdomain including `username` and `password` for use in the configuration. **do not use your infomaniak admin username and password in the configuration!**
36 |
37 | If you only plan on using IPv4, add your current IPv4 Address. If you only plan on using IPv6, add your current IPv6 Address. If you plan to use dual-stack (IPv4 and IPv6) addresses, it does not matter what ip-address you put in the dialog.
38 |
--------------------------------------------------------------------------------
/docs/inwx.md:
--------------------------------------------------------------------------------
1 | # INWX
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "inwx",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/ionos.md:
--------------------------------------------------------------------------------
1 | # Ionos
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "ionos",
12 | "domain": "domain.com",
13 | "api_key": "api_key",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
24 | - `"api_key"` is your API key, obtained from [creating an API key](https://www.ionos.com/help/domains/configuring-your-ip-address/set-up-dynamic-dns-with-company-name/#c181598)
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
--------------------------------------------------------------------------------
/docs/linode.md:
--------------------------------------------------------------------------------
1 | # Linode
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "linode",
12 | "domain": "domain.com",
13 | "token": "token",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
24 | - `"token"`
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | 1. Create a personal access token with `domains` set, with read and write privileges, ideally that never expires. You can refer to [@AnujRNair's comment](https://github.com/qdm12/ddns-updater/pull/144#discussion_r559292678) and to [Linode's guide](https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/).
34 | 1. The program will create the A or AAAA record for you if it doesn't exist already.
35 |
--------------------------------------------------------------------------------
/docs/loopia.md:
--------------------------------------------------------------------------------
1 | # Loopia
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "loopia",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`). It cannot be a wildcard domain.
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
--------------------------------------------------------------------------------
/docs/luadns.md:
--------------------------------------------------------------------------------
1 | # LuaDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "luadns",
12 | "domain": "domain.com",
13 | "email": "email",
14 | "token": "token",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
25 | - `"email"`
26 | - `"token"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
35 | 1. Go to [api.luadns.com/settings](https://api.luadns.com/settings)
36 | 1. Enable API access
37 | 1. Obtain your API token and replace it in the parameters as the value for `token`
38 |
--------------------------------------------------------------------------------
/docs/myaddr.md:
--------------------------------------------------------------------------------
1 | # [Myaddr](https://myaddr.tools/)
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "myaddr",
12 | "domain": "your-name.myaddr.tools",
13 | "key": "key",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` - the **single** domain to update; note the `key` below updates all records and subdomains for this domain. It should be `your-name.myaddr.tools`.
24 | - `"key"` - the private key corresponding to the domain to update
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | Claim a subdomain at [myaddr.tools](https://myaddr.tools/)
34 |
--------------------------------------------------------------------------------
/docs/name.com.md:
--------------------------------------------------------------------------------
1 | # Name.com
2 |
3 |
4 |
5 | ## Configuration
6 |
7 | ### Example
8 |
9 | ```json
10 | {
11 | "settings": [
12 | {
13 | "provider": "name.com",
14 | "domain": "domain.com",
15 | "username": "username",
16 | "token": "token",
17 | "ttl": 300,
18 | "ip_version": "ipv4",
19 | "ipv6_suffix": ""
20 | }
21 | ]
22 | }
23 | ```
24 |
25 | ### Compulsory parameters
26 |
27 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
28 | - `"username"` is your account username
29 | - `"token"` which you can obtain from [www.name.com/account/settings/api](https://www.name.com/account/settings/api)
30 |
31 | ### Optional parameters
32 |
33 | - `"ttl"` is the time this record can be cached for in seconds. Name.com allows a minimum TTL of 300, or 5 minutes. Name.com defaults to 300 if not provided.
34 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
35 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
36 |
--------------------------------------------------------------------------------
/docs/namecheap.md:
--------------------------------------------------------------------------------
1 | # Namecheap
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "namecheap",
12 | "domain": "domain.com",
13 | "password": "password"
14 | }
15 | ]
16 | }
17 | ```
18 |
19 | ### Compulsory parameters
20 |
21 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
22 | - `"password"`
23 |
24 | ### Optional parameters
25 |
26 | Note that Namecheap only supports ipv4 addresses for now.
27 |
28 | ## Domain setup
29 |
30 | [](https://www.namecheap.com/)
31 |
32 | 1. Create a Namecheap account and buy a domain name - *example.com* as an example
33 | 1. Login to Namecheap at [https://www.namecheap.com/myaccount/login/](https://www.namecheap.com/myaccount/login/)
34 |
35 | 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)
36 |
37 | 1. For each host you want to add (if you don't know, create one record with the host set to `*`):
38 | 1. In the *HOST RECORDS* section, click on *ADD NEW RECORD*
39 |
40 | 
41 |
42 | 1. Select the following settings and create the *A + Dynamic DNS Record*:
43 |
44 | 
45 |
46 | 1. Scroll down and turn on the switch for *DYNAMIC DNS*
47 |
48 | 
49 |
50 | 1. The Dynamic DNS Password will appear, which is `0e4512a9c45a4fe88313bcc2234bf547` in this example.
51 |
52 | 
53 |
--------------------------------------------------------------------------------
/docs/netcup.md:
--------------------------------------------------------------------------------
1 | # Netcup
2 |
3 | ## Configuration
4 |
5 | Note: This implementation does not require a domain reseller account. The warning in the dashboard can be ignored.
6 |
7 | Also keep in mind, that TTL, Expire, Retry and Refresh values of the given Domain are not updated. They can be manually set in the dashboard. For DDNS purposes low numbers should be used.
8 |
9 | ### Example
10 |
11 | ```json
12 | {
13 | "settings": [
14 | {
15 | "provider": "netcup",
16 | "domain": "domain.com",
17 | "api_key": "xxxxx",
18 | "password": "yyyyy",
19 | "customer_number": "111111",
20 | "ip_version": "ipv4",
21 | "ipv6_suffix": ""
22 | }
23 | ]
24 | }
25 | ```
26 |
27 | ### Compulsory parameters
28 |
29 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`) or the wildcard `*.example.com`.
30 | - `"api_key"` is your api key (generated in the [customercontrolpanel](https://www.customercontrolpanel.de))
31 | - `"password"` is your api password (generated in the [customercontrolpanel](https://www.customercontrolpanel.de)). Netcup only allows one ApiPassword. This is not the account password. This password is used for all api keys.
32 | - `"customer_number"` is your customer number (viewable in the [customercontrolpanel](https://www.customercontrolpanel.de) next to your name). As seen in the example above, provide the number as string value.
33 |
34 | ### Optional parameters
35 |
36 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
37 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
38 |
--------------------------------------------------------------------------------
/docs/njalla.md:
--------------------------------------------------------------------------------
1 | # Njalla
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "njalla",
12 | "domain": "domain.com",
13 | "key": "key",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
24 | - `"key"` is the key for your record
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | See [https://njal.la/docs/ddns](https://njal.la/docs/ddns/)
34 |
--------------------------------------------------------------------------------
/docs/noip.md:
--------------------------------------------------------------------------------
1 | # NoIP
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "noip",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/nowdns.md:
--------------------------------------------------------------------------------
1 | # Now-DNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "nowdns",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` your full domain name (FQDN)
25 | - `"username"` your email address
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
--------------------------------------------------------------------------------
/docs/opendns.md:
--------------------------------------------------------------------------------
1 | # OpenDNS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "opendns",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"`
26 | - `"password"`
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/ovh.md:
--------------------------------------------------------------------------------
1 | # OVH
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "ovh",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 |
26 | #### Using DynHost
27 |
28 | - `"username"`
29 | - `"password"`
30 |
31 | #### OR Using ZoneDNS
32 |
33 | - `"api_endpoint"` default value is `"ovh-eu"`
34 | - `"app_key"` which you can create at [eu.api.ovh.com/createApp](https://eu.api.ovh.com/createApp/)
35 | - `"app_secret"`
36 | - `"consumer_key"` which you can get at [www.ovh.com/auth/api/createToken](https://www.ovh.com/auth/api/createToken)
37 |
38 | The ZoneDNS implementation allows you to update any record name including *.yourdomain.tld
39 |
40 | ### Optional parameters
41 |
42 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
43 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
44 | - `"mode"` select between two modes, OVH's dynamic hosting service (`"dynamic"`) or OVH's API (`"api"`). Default is `"dynamic"`
45 |
46 | ## Domain setup
47 |
48 | - If you use DynHost: [docs.ovh.com/ie/en/domains/hosting_dynhost](https://docs.ovh.com/ie/en/domains/hosting_dynhost/)
49 | - If you use the ZoneDNS API: [docs.ovh.com/gb/en/customer/first-steps-with-ovh-api](https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/)
50 |
--------------------------------------------------------------------------------
/docs/route53.md:
--------------------------------------------------------------------------------
1 | # AWS
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "route53",
12 | "domain": "domain.com",
13 | "ip_version": "ipv4",
14 | "ipv6_suffix": "",
15 | "access_key": "ffffffffffffffffffff",
16 | "secret_key": "ffffffffffffffffffffffffffffffffffffffff",
17 | "zone_id": "A30888735ZF12K83Z6F00",
18 | "ttl": 300
19 | }
20 | ]
21 | }
22 | ```
23 |
24 | ### Compulsory parameters
25 |
26 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
27 | - `"access_key"` is the `AWS_ACCESS_KEY`
28 | - `"secret_key"` is the `AWS_SECRET_ACCESS_KEY`
29 | - `"zone_id"` is identification of your hosted zone
30 |
31 | ### Optional parameters
32 |
33 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
34 | - `"ipv6_suffix"` is the IPv6 interface identifiersuffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
35 | - `"ttl"` amount of time, in seconds, that you want DNS recursive resolvers to cache information about this record. Defaults to `300`.
36 |
37 | ## Domain setup
38 |
39 | Amazon has [an extensive documentation on registering or tranfering your domain to route53](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html).
40 |
41 | ### User permissions
42 |
43 | Create a policy to grant access to change record sets, you can use a wildcard `*` in case you want to grant access to all your hosted zones.
44 |
45 | ```json
46 | {
47 | "Version": "2012-10-17",
48 | "Statement": [
49 | {
50 | "Effect": "Allow",
51 | "Action": "route53:ChangeResourceRecordSets",
52 | "Resource": "arn:aws:route53:::hostedzone/A30888735ZF12K83Z6F00"
53 | }
54 | ]
55 | }
56 | ```
57 |
--------------------------------------------------------------------------------
/docs/selfhost.de.md:
--------------------------------------------------------------------------------
1 | # Selfhost.de
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "selfhost.de",
12 | "domain": "domain.com",
13 | "username": "username",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"username"` is your DynDNS username
26 | - `"password"` is your DynDNS password
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
--------------------------------------------------------------------------------
/docs/servercow.md:
--------------------------------------------------------------------------------
1 | # Servercow
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "servercow",
12 | "domain": "domain.com",
13 | "username": "servercow_username",
14 | "password": "servercow_password",
15 | "ttl": 600,
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
26 | - `"username"` is the username for your DNS API User
27 | - `"password"` is the password for your DNS API User
28 |
29 | ### Optional parameters
30 |
31 | - `"ttl"` can be set to an integer value for record TTL in seconds (if not set the default is 120)
32 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
33 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
34 |
35 | ## Domain setup
36 |
37 | See [their article](https://cp.servercow.de/en/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/)
38 |
--------------------------------------------------------------------------------
/docs/spdyn.md:
--------------------------------------------------------------------------------
1 | # Spdyn.de
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "spdyn",
12 | "domain": "domain.com",
13 | "user": "user",
14 | "password": "password",
15 | "token": "token",
16 | "ip_version": "ipv4",
17 | "ipv6_suffix": ""
18 | }
19 | ]
20 | }
21 | ```
22 |
23 | ### Compulsory parameters
24 |
25 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
26 |
27 | #### Using user and password
28 |
29 | - `"user"` is the name of a user who can update this host
30 | - `"password"` is the password of a user who can update this host
31 |
32 | #### Using update tokens
33 |
34 | - `"token"` is your update token
35 |
36 | ### Optional parameters
37 |
38 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
39 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
40 |
--------------------------------------------------------------------------------
/docs/strato.md:
--------------------------------------------------------------------------------
1 | # Strato
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "strato",
12 | "domain": "domain.com",
13 | "password": "password",
14 | "ip_version": "ipv4",
15 | "ipv6_suffix": ""
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
24 | - `"password"` is your dyndns password
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 |
31 | ## Domain setup
32 |
33 | See [their article](https://www.strato.com/faq/en_us/domain/this-is-how-easy-it-is-to-set-up-dyndns-for-your-domains/)
34 |
--------------------------------------------------------------------------------
/docs/variomedia.md:
--------------------------------------------------------------------------------
1 | # Variomedia
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "variomedia",
12 | "domain": "domain.com",
13 | "email": "email@domain.com",
14 | "password": "password",
15 | "ip_version": "ipv4",
16 | "ipv6_suffix": ""
17 | }
18 | ]
19 | }
20 | ```
21 |
22 | ### Compulsory parameters
23 |
24 | - `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`).
25 | - `"email"`
26 | - `"password"` is your DNS settings password, not your account password ⚠️
27 |
28 | ### Optional parameters
29 |
30 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
31 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
32 |
33 | ## Domain setup
34 |
35 | See [dyndns.variomedia.de](https://dyndns.variomedia.de/)
36 |
--------------------------------------------------------------------------------
/docs/vultr.md:
--------------------------------------------------------------------------------
1 | # Vultr
2 |
3 | ## Configuration
4 |
5 | ### Example
6 |
7 | ```json
8 | {
9 | "settings": [
10 | {
11 | "provider": "vultr",
12 | "domain": "potato.example.com",
13 | "apikey": "AAAAAAAAAAAAAAA",
14 | "ttl": 300,
15 | "ip_version": "ipv4"
16 | }
17 | ]
18 | }
19 | ```
20 |
21 | ### Compulsory parameters
22 |
23 | - `"domain"` is the domain to update. It can be a root domain (i.e. `example.com`) or a subdomain (i.e. `potato.example.com`) or a wildcard (i.e. `*.example.com`). In case of a wildcard, it only works if there is no existing wildcard records of any record type.
24 | - `"apikey"` is your API key which can be obtained from [my.vultr.com/settings/](https://my.vultr.com/settings/#settingsapi).
25 |
26 | ### Optional parameters
27 |
28 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
29 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
30 | - `"ttl"` is the record TTL which defaults to 900 seconds.
31 |
--------------------------------------------------------------------------------
/docs/zoneedit.md:
--------------------------------------------------------------------------------
1 | # Zoneedit
2 |
3 | ## Configuration
4 |
5 | ⚠️ zoneedit.com for some reason requires at least a 10 minutes period between update request sent.
6 |
7 | DDNS-Updater only sends update requests when it detects your domain name IP address mismatches your current public IP address,
8 | so it should be fine in most cases since this happens rarely (in hours/days). But in case it happens and you want to avoid this,
9 | set the environment variable as `PERIOD=11m` to check your public IP address and update every 11 minutes only.
10 |
11 | ### Example
12 |
13 | ```json
14 | {
15 | "settings": [
16 | {
17 | "provider": "zoneedit",
18 | "domain": "domain.com",
19 | "username": "username",
20 | "token": "token",
21 | "ip_version": "ipv4",
22 | "ipv6_suffix": ""
23 | }
24 | ]
25 | }
26 | ```
27 |
28 | ### Compulsory parameters
29 |
30 | - `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
31 | - `"username"`
32 | - `"token"`
33 |
34 | ### Optional parameters
35 |
36 | - `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
37 | - `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
38 |
39 | ## Domain setup
40 |
41 | [support.zoneedit.com/en/knowledgebase/article/dynamic-dns](https://support.zoneedit.com/en/knowledgebase/article/dynamic-dns)
42 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/qdm12/ddns-updater
2 |
3 | go 1.23.0
4 |
5 | require (
6 | github.com/breml/rootcerts v0.2.19
7 | github.com/containrrr/shoutrrr v0.8.0
8 | github.com/go-chi/chi/v5 v5.2.0
9 | github.com/golang/mock v1.6.0
10 | github.com/miekg/dns v1.1.62
11 | github.com/qdm12/goservices v0.1.0
12 | github.com/qdm12/gosettings v0.4.4
13 | github.com/qdm12/gosplash v0.2.0
14 | github.com/qdm12/gotree v0.3.0
15 | github.com/qdm12/log v0.1.0
16 | github.com/stretchr/testify v1.10.0
17 | golang.org/x/mod v0.22.0
18 | golang.org/x/net v0.33.0
19 | golang.org/x/oauth2 v0.24.0
20 | )
21 |
22 | require (
23 | cloud.google.com/go/compute/metadata v0.3.0 // indirect
24 | github.com/davecgh/go-spew v1.1.1 // indirect
25 | github.com/fatih/color v1.15.0 // indirect
26 | github.com/go-logr/logr v1.4.1 // indirect
27 | github.com/golang/protobuf v1.5.4 // indirect
28 | github.com/mattn/go-colorable v0.1.13 // indirect
29 | github.com/mattn/go-isatty v0.0.17 // indirect
30 | github.com/pmezard/go-difflib v1.0.0 // indirect
31 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
32 | golang.org/x/sync v0.7.0 // indirect
33 | golang.org/x/sys v0.28.0 // indirect
34 | golang.org/x/tools v0.22.0 // indirect
35 | google.golang.org/protobuf v1.34.1 // indirect
36 | gopkg.in/yaml.v3 v3.0.1 // indirect
37 | kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 // indirect
38 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect
39 | )
40 |
--------------------------------------------------------------------------------
/internal/backup/interfaces.go:
--------------------------------------------------------------------------------
1 | package backup
2 |
3 | type Logger interface {
4 | Info(message string)
5 | }
6 |
--------------------------------------------------------------------------------
/internal/backup/zip.go:
--------------------------------------------------------------------------------
1 | package backup
2 |
3 | import (
4 | "archive/zip"
5 | "io"
6 | "os"
7 | )
8 |
9 | func zipFiles(outputFilepath string, inputFilepaths ...string) error {
10 | f, err := os.Create(outputFilepath)
11 | if err != nil {
12 | return err
13 | }
14 | defer f.Close()
15 | w := zip.NewWriter(f)
16 | defer w.Close()
17 | for _, filepath := range inputFilepaths {
18 | err = addFile(w, filepath)
19 | if err != nil {
20 | return err
21 | }
22 | }
23 | return nil
24 | }
25 |
26 | func addFile(w *zip.Writer, filepath string) error {
27 | f, err := os.Open(filepath)
28 | if err != nil {
29 | return err
30 | }
31 | defer f.Close()
32 | info, err := f.Stat()
33 | if err != nil {
34 | return err
35 | }
36 | header, err := zip.FileInfoHeader(info)
37 | if err != nil {
38 | return err
39 | }
40 | // Using FileInfoHeader() above only uses the basename of the file. If we want
41 | // to preserve the folder structure we can overwrite this with the full path.
42 | // header.Name = filepath
43 | header.Method = zip.Deflate
44 | ioWriter, err := w.CreateHeader(header)
45 | if err != nil {
46 | return err
47 | }
48 | _, err = io.Copy(ioWriter, f)
49 | return err
50 | }
51 |
--------------------------------------------------------------------------------
/internal/config/backup.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/qdm12/gosettings"
7 | "github.com/qdm12/gosettings/reader"
8 | "github.com/qdm12/gotree"
9 | )
10 |
11 | type Backup struct {
12 | Period *time.Duration
13 | Directory *string
14 | }
15 |
16 | func (b *Backup) setDefaults() {
17 | b.Period = gosettings.DefaultPointer(b.Period, 0)
18 | b.Directory = gosettings.DefaultPointer(b.Directory, "./data")
19 | }
20 |
21 | func (b Backup) Validate() (err error) {
22 | return nil
23 | }
24 |
25 | func (b Backup) String() string {
26 | return b.toLinesNode().String()
27 | }
28 |
29 | func (b Backup) toLinesNode() *gotree.Node {
30 | if *b.Period == 0 {
31 | return gotree.New("Backup: disabled")
32 | }
33 | node := gotree.New("Backup")
34 | node.Appendf("Period: %s", b.Period)
35 | node.Appendf("Directory: %s", *b.Directory)
36 | return node
37 | }
38 |
39 | func (b *Backup) read(reader *reader.Reader) (err error) {
40 | b.Period, err = reader.DurationPtr("BACKUP_PERIOD")
41 | if err != nil {
42 | return err
43 | }
44 |
45 | b.Directory = reader.Get("BACKUP_DIRECTORY")
46 | return nil
47 | }
48 |
--------------------------------------------------------------------------------
/internal/config/client.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/qdm12/gosettings"
7 | "github.com/qdm12/gosettings/reader"
8 | "github.com/qdm12/gotree"
9 | )
10 |
11 | type Client struct {
12 | Timeout time.Duration
13 | }
14 |
15 | func (c *Client) setDefaults() {
16 | const defaultTimeout = 20 * time.Second
17 | c.Timeout = gosettings.DefaultComparable(c.Timeout, defaultTimeout)
18 | }
19 |
20 | func (c Client) Validate() (err error) {
21 | return nil
22 | }
23 |
24 | func (c Client) String() string {
25 | return c.toLinesNode().String()
26 | }
27 |
28 | func (c Client) toLinesNode() *gotree.Node {
29 | node := gotree.New("HTTP client")
30 | node.Appendf("Timeout: %s", c.Timeout)
31 | return node
32 | }
33 |
34 | func (c *Client) read(reader *reader.Reader) (err error) {
35 | c.Timeout, err = reader.Duration("HTTP_TIMEOUT")
36 | if err != nil {
37 | return err
38 | }
39 |
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/internal/config/health.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "net/url"
6 | "os"
7 |
8 | "github.com/qdm12/gosettings"
9 | "github.com/qdm12/gosettings/reader"
10 | "github.com/qdm12/gosettings/validate"
11 | "github.com/qdm12/gotree"
12 | )
13 |
14 | type Health struct {
15 | // ServerAddress is the listening address:port of the
16 | // health server, which defaults to the empty string,
17 | // meaning the server will not run.
18 | ServerAddress *string
19 | HealthchecksioBaseURL string
20 | HealthchecksioUUID *string
21 | }
22 |
23 | func (h *Health) SetDefaults() {
24 | h.ServerAddress = gosettings.DefaultPointer(h.ServerAddress, "")
25 | h.HealthchecksioBaseURL = gosettings.DefaultComparable(h.HealthchecksioBaseURL, "https://hc-ping.com")
26 | h.HealthchecksioUUID = gosettings.DefaultPointer(h.HealthchecksioUUID, "")
27 | }
28 |
29 | func (h Health) Validate() (err error) {
30 | if *h.ServerAddress != "" {
31 | err = validate.ListeningAddress(*h.ServerAddress, os.Getuid())
32 | if err != nil {
33 | return fmt.Errorf("server listening address: %w", err)
34 | }
35 | }
36 |
37 | _, err = url.Parse(h.HealthchecksioBaseURL)
38 | if err != nil {
39 | return fmt.Errorf("healthchecks.io base URL: %w", err)
40 | }
41 |
42 | return nil
43 | }
44 |
45 | func (h Health) String() string {
46 | return h.toLinesNode().String()
47 | }
48 |
49 | func (h Health) toLinesNode() *gotree.Node {
50 | node := gotree.New("Health")
51 | if *h.ServerAddress == "" {
52 | node.Appendf("Server is disabled")
53 | } else {
54 | node.Appendf("Server listening address: %s", *h.ServerAddress)
55 | }
56 | if *h.HealthchecksioUUID != "" {
57 | node.Appendf("Healthchecks.io base URL: %s", h.HealthchecksioBaseURL)
58 | node.Appendf("Healthchecks.io UUID: %s", *h.HealthchecksioUUID)
59 | }
60 | return node
61 | }
62 |
63 | func (h *Health) Read(reader *reader.Reader) {
64 | h.ServerAddress = reader.Get("HEALTH_SERVER_ADDRESS")
65 | h.HealthchecksioBaseURL = reader.String("HEALTH_HEALTHCHECKSIO_BASE_URL")
66 | h.HealthchecksioUUID = reader.Get("HEALTH_HEALTHCHECKSIO_UUID")
67 | }
68 |
--------------------------------------------------------------------------------
/internal/config/helpers.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const all = "all"
4 |
--------------------------------------------------------------------------------
/internal/config/logger.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/qdm12/gosettings"
8 | "github.com/qdm12/gosettings/reader"
9 | "github.com/qdm12/gosettings/validate"
10 | "github.com/qdm12/gotree"
11 | "github.com/qdm12/log"
12 | )
13 |
14 | type Logger struct {
15 | Level string
16 | Caller string
17 | }
18 |
19 | func (l *Logger) setDefaults() {
20 | l.Level = gosettings.DefaultComparable(l.Level, log.LevelInfo.String())
21 | l.Caller = gosettings.DefaultComparable(l.Caller, "hidden")
22 | }
23 |
24 | func (l Logger) Validate() (err error) {
25 | _, err = log.ParseLevel(l.Level)
26 | if err != nil {
27 | return fmt.Errorf("log level: %w", err)
28 | }
29 |
30 | err = validate.IsOneOf(l.Caller, "hidden", "short")
31 | if err != nil {
32 | return fmt.Errorf("log caller: %w", err)
33 | }
34 |
35 | return nil
36 | }
37 |
38 | func (l Logger) ToOptions() (options []log.Option) {
39 | level, _ := log.ParseLevel(l.Level)
40 | options = append(options, log.SetLevel(level))
41 | if l.Caller == "short" {
42 | options = append(options, log.SetCallerFile(true), log.SetCallerLine(true))
43 | }
44 | return options
45 | }
46 |
47 | func (l Logger) String() string {
48 | return l.toLinesNode().String()
49 | }
50 |
51 | func (l Logger) toLinesNode() *gotree.Node {
52 | node := gotree.New("Logger")
53 | node.Appendf("Level: %s", l.Level)
54 | node.Appendf("Caller: %s", l.Caller)
55 | return node
56 | }
57 |
58 | func (l *Logger) read(reader *reader.Reader) {
59 | l.Level = reader.String("LOG_LEVEL")
60 | // Retro compatibility
61 | if strings.ToLower(l.Level) == "warning" {
62 | l.Level = "warn"
63 | }
64 | l.Caller = reader.String("LOG_CALLER")
65 | }
66 |
--------------------------------------------------------------------------------
/internal/config/paths.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "io/fs"
6 | "path/filepath"
7 | "strconv"
8 |
9 | "github.com/qdm12/gosettings"
10 | "github.com/qdm12/gosettings/reader"
11 | "github.com/qdm12/gotree"
12 | )
13 |
14 | type Paths struct {
15 | DataDir *string
16 | Config *string
17 | // Umask is the custom umask to use for the system, if different than zero.
18 | // If it is set to zero, the system umask is unchanged.
19 | // It cannot be nil in the internal state.
20 | Umask *fs.FileMode
21 | }
22 |
23 | func (p *Paths) setDefaults() {
24 | p.DataDir = gosettings.DefaultPointer(p.DataDir, "./data")
25 | defaultConfig := filepath.Join(*p.DataDir, "config.json")
26 | p.Config = gosettings.DefaultPointer(p.Config, defaultConfig)
27 | p.Umask = gosettings.DefaultPointer(p.Umask, fs.FileMode(0))
28 | }
29 |
30 | func (p Paths) Validate() (err error) {
31 | return nil
32 | }
33 |
34 | func (p Paths) String() string {
35 | return p.toLinesNode().String()
36 | }
37 |
38 | func (p Paths) toLinesNode() *gotree.Node {
39 | node := gotree.New("Paths")
40 | node.Appendf("Data directory: %s", *p.DataDir)
41 | node.Appendf("Config file: %s", *p.Config)
42 | umaskString := "system default"
43 | if *p.Umask != 0 {
44 | umaskString = p.Umask.String()
45 | }
46 | node.Appendf("Umask: %s", umaskString)
47 | return node
48 | }
49 |
50 | func (p *Paths) read(reader *reader.Reader) (err error) {
51 | p.DataDir = reader.Get("DATADIR")
52 | p.Config = reader.Get("CONFIG_FILEPATH")
53 |
54 | umaskString := reader.String("UMASK")
55 | if umaskString != "" {
56 | umask, err := parseUmask(umaskString)
57 | if err != nil {
58 | return fmt.Errorf("parse umask: %w", err)
59 | }
60 | p.Umask = &umask
61 | }
62 |
63 | return nil
64 | }
65 |
66 | func parseUmask(s string) (umask fs.FileMode, err error) {
67 | const base, bitSize = 8, 32
68 | umaskUint64, err := strconv.ParseUint(s, base, bitSize)
69 | if err != nil {
70 | return 0, err
71 | }
72 | return fs.FileMode(umaskUint64), nil
73 | }
74 |
--------------------------------------------------------------------------------
/internal/config/paths_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io/fs"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func Test_parseUmask(t *testing.T) {
11 | t.Parallel()
12 |
13 | testCases := map[string]struct {
14 | s string
15 | umask fs.FileMode
16 | errMessage string
17 | }{
18 | "invalid": {
19 | s: "a",
20 | errMessage: `strconv.ParseUint: parsing "a": invalid syntax`,
21 | },
22 | "704": {
23 | s: "704",
24 | umask: 0o704,
25 | },
26 | "0704": {
27 | s: "0704",
28 | umask: 0o0704,
29 | },
30 | }
31 |
32 | for name, testCase := range testCases {
33 | t.Run(name, func(t *testing.T) {
34 | t.Parallel()
35 |
36 | umask, err := parseUmask(testCase.s)
37 |
38 | if testCase.errMessage != "" {
39 | assert.EqualError(t, err, testCase.errMessage)
40 | } else {
41 | assert.NoError(t, err)
42 | }
43 | assert.Equal(t, testCase.umask, umask)
44 | })
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/internal/config/retrocompat.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Warner interface {
4 | Warnf(format string, a ...interface{})
5 | }
6 |
7 | func handleDeprecated(warner Warner, oldKey, newKey string) {
8 | warner.Warnf("You are using an old environment variable %s, please change it to %s",
9 | oldKey, newKey)
10 | }
11 |
--------------------------------------------------------------------------------
/internal/config/server.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/qdm12/gosettings"
8 | "github.com/qdm12/gosettings/reader"
9 | "github.com/qdm12/gosettings/validate"
10 | "github.com/qdm12/gotree"
11 | )
12 |
13 | type Server struct {
14 | Enabled *bool
15 | ListeningAddress string
16 | RootURL string
17 | }
18 |
19 | func (s *Server) setDefaults() {
20 | s.Enabled = gosettings.DefaultPointer(s.Enabled, true)
21 | s.ListeningAddress = gosettings.DefaultComparable(s.ListeningAddress, ":8000")
22 | s.RootURL = gosettings.DefaultComparable(s.RootURL, "/")
23 | }
24 |
25 | func (s Server) Validate() (err error) {
26 | err = validate.ListeningAddress(s.ListeningAddress, os.Getuid())
27 | if err != nil {
28 | return fmt.Errorf("listening address: %w", err)
29 | }
30 |
31 | // TODO validate RootURL
32 |
33 | return nil
34 | }
35 |
36 | func (s Server) String() string {
37 | return s.toLinesNode().String()
38 | }
39 |
40 | func (s Server) toLinesNode() *gotree.Node {
41 | if !*s.Enabled {
42 | return gotree.New("Server: disabled")
43 | }
44 | node := gotree.New("Server")
45 | node.Appendf("Listening address: %s", s.ListeningAddress)
46 | node.Appendf("Root URL: %s", s.RootURL)
47 | return node
48 | }
49 |
50 | func (s *Server) read(reader *reader.Reader, warner Warner) (err error) {
51 | s.Enabled, err = reader.BoolPtr("SERVER_ENABLED")
52 | if err != nil {
53 | return err
54 | }
55 |
56 | s.RootURL = reader.String("ROOT_URL")
57 |
58 | // Retro-compatibility
59 | port, err := reader.Uint16Ptr("LISTENING_PORT") // TODO change to address
60 | if err != nil {
61 | handleDeprecated(warner, "LISTENING_PORT", "LISTENING_ADDRESS")
62 | return err
63 | } else if port != nil {
64 | s.ListeningAddress = fmt.Sprintf(":%d", *port)
65 | }
66 |
67 | s.ListeningAddress = reader.String("LISTENING_ADDRESS")
68 |
69 | return err
70 | }
71 |
--------------------------------------------------------------------------------
/internal/config/settings_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_Settings_String(t *testing.T) {
10 | t.Parallel()
11 |
12 | var defaultSettings Config
13 | defaultSettings.SetDefaults()
14 |
15 | s := defaultSettings.String()
16 |
17 | const expected = `Settings summary:
18 | ├── HTTP client
19 | | └── Timeout: 20s
20 | ├── Update
21 | | ├── Period: 10m0s
22 | | └── Cooldown: 5m0s
23 | ├── Public IP fetching
24 | | ├── HTTP enabled: yes
25 | | ├── HTTP IP providers
26 | | | └── all
27 | | ├── HTTP IPv4 providers
28 | | | └── all
29 | | ├── HTTP IPv6 providers
30 | | | └── all
31 | | ├── DNS enabled: yes
32 | | ├── DNS timeout: 3s
33 | | └── DNS over TLS providers
34 | | └── all
35 | ├── Resolver: use Go default resolver
36 | ├── Server
37 | | ├── Listening address: :8000
38 | | └── Root URL: /
39 | ├── Health
40 | | └── Server is disabled
41 | ├── Paths
42 | | ├── Data directory: ./data
43 | | ├── Config file: data/config.json
44 | | └── Umask: system default
45 | ├── Backup: disabled
46 | └── Logger
47 | ├── Level: INFO
48 | └── Caller: hidden`
49 | assert.Equal(t, expected, s)
50 | }
51 |
--------------------------------------------------------------------------------
/internal/config/update.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "strconv"
5 | "time"
6 |
7 | "github.com/qdm12/gosettings"
8 | "github.com/qdm12/gosettings/reader"
9 | "github.com/qdm12/gotree"
10 | )
11 |
12 | type Update struct {
13 | Period time.Duration
14 | Cooldown time.Duration
15 | }
16 |
17 | func (u *Update) setDefaults() {
18 | const defaultPeriod = 10 * time.Minute
19 | u.Period = gosettings.DefaultComparable(u.Period, defaultPeriod)
20 | const defaultCooldown = 5 * time.Minute
21 | u.Cooldown = gosettings.DefaultComparable(u.Cooldown, defaultCooldown)
22 | }
23 |
24 | func (u Update) Validate() (err error) {
25 | return nil
26 | }
27 |
28 | func (u Update) String() string {
29 | return u.toLinesNode().String()
30 | }
31 |
32 | func (u Update) toLinesNode() *gotree.Node {
33 | node := gotree.New("Update")
34 | node.Appendf("Period: %s", u.Period)
35 | node.Appendf("Cooldown: %s", u.Cooldown)
36 | return node
37 | }
38 |
39 | func (u *Update) read(reader *reader.Reader, warner Warner) (err error) {
40 | u.Period, err = readUpdatePeriod(reader, warner)
41 | if err != nil {
42 | return err
43 | }
44 |
45 | u.Cooldown, err = reader.Duration("UPDATE_COOLDOWN_PERIOD")
46 | return err
47 | }
48 |
49 | func readUpdatePeriod(r *reader.Reader, warner Warner) (period time.Duration, err error) {
50 | // Retro-compatibility: DELAY variable name
51 | delayStringPtr := r.Get("DELAY")
52 | if delayStringPtr != nil {
53 | handleDeprecated(warner, "DELAY", "PERIOD")
54 | // Retro-compatibility: integer only, treated as seconds
55 | delayInt, err := strconv.Atoi(*delayStringPtr)
56 | if err == nil {
57 | return time.Duration(delayInt) * time.Second, nil
58 | }
59 |
60 | return time.ParseDuration(*delayStringPtr)
61 | }
62 |
63 | return r.Duration("PERIOD")
64 | }
65 |
--------------------------------------------------------------------------------
/internal/constants/status.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | import "github.com/qdm12/ddns-updater/internal/models"
4 |
5 | const (
6 | FAIL models.Status = "failure"
7 | SUCCESS models.Status = "success"
8 | UPTODATE models.Status = "up to date"
9 | UPDATING models.Status = "updating"
10 | UNSET models.Status = "unset"
11 | )
12 |
--------------------------------------------------------------------------------
/internal/data/data.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "context"
5 | "sync"
6 |
7 | "github.com/qdm12/ddns-updater/internal/records"
8 | )
9 |
10 | type Database struct {
11 | data []records.Record
12 | sync.RWMutex
13 | persistentDB PersistentDatabase
14 | }
15 |
16 | // NewDatabase creates a new in memory database.
17 | func NewDatabase(data []records.Record, persistentDB PersistentDatabase) *Database {
18 | return &Database{
19 | data: data,
20 | persistentDB: persistentDB,
21 | }
22 | }
23 |
24 | func (db *Database) String() string {
25 | return "database"
26 | }
27 |
28 | func (db *Database) Start(_ context.Context) (_ <-chan error, err error) {
29 | return nil, nil //nolint:nilnil
30 | }
31 |
32 | func (db *Database) Stop() (err error) {
33 | db.Lock() // ensure write operation finishes
34 | defer db.Unlock()
35 | return db.persistentDB.Close()
36 | }
37 |
--------------------------------------------------------------------------------
/internal/data/interfaces.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "net/netip"
5 | "time"
6 | )
7 |
8 | type PersistentDatabase interface {
9 | Close() error
10 | StoreNewIP(domain, owner string, ip netip.Addr, t time.Time) (err error)
11 | }
12 |
--------------------------------------------------------------------------------
/internal/data/memory.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/qdm12/ddns-updater/internal/records"
8 | )
9 |
10 | var ErrRecordNotFound = errors.New("record not found")
11 |
12 | func (db *Database) Select(id uint) (record records.Record, err error) {
13 | db.RLock()
14 | defer db.RUnlock()
15 | if id > uint(len(db.data))-1 {
16 | return record, fmt.Errorf("%w: for id %d", ErrRecordNotFound, id)
17 | }
18 | return db.data[id], nil
19 | }
20 |
21 | func (db *Database) SelectAll() (records []records.Record) {
22 | db.RLock()
23 | defer db.RUnlock()
24 | return db.data
25 | }
26 |
--------------------------------------------------------------------------------
/internal/data/persistence.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/qdm12/ddns-updater/internal/records"
7 | )
8 |
9 | func (db *Database) Update(id uint, record records.Record) (err error) {
10 | db.Lock()
11 | defer db.Unlock()
12 | if id > uint(len(db.data))-1 {
13 | return fmt.Errorf("%w: for id %d", ErrRecordNotFound, id)
14 | }
15 | currentCount := len(db.data[id].History)
16 | newCount := len(record.History)
17 | db.data[id] = record
18 | // new IP address added
19 | if newCount > currentCount {
20 | if err := db.persistentDB.StoreNewIP(
21 | record.Provider.Domain(),
22 | record.Provider.Owner(),
23 | record.History.GetCurrentIP(),
24 | record.History.GetSuccessTime(),
25 | ); err != nil {
26 | return err
27 | }
28 | }
29 | return nil
30 | }
31 |
--------------------------------------------------------------------------------
/internal/health/check.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/netip"
8 | "strings"
9 |
10 | "github.com/qdm12/ddns-updater/internal/constants"
11 | )
12 |
13 | func MakeIsHealthy(db AllSelecter, resolver LookupIPer) func(ctx context.Context) error {
14 | return func(ctx context.Context) (err error) {
15 | return isHealthy(ctx, db, resolver)
16 | }
17 | }
18 |
19 | var (
20 | ErrRecordUpdateFailed = errors.New("record update failed")
21 | ErrRecordIPNotSet = errors.New("record IP not set")
22 | ErrLookupMismatch = errors.New("lookup IP addresses do not match")
23 | )
24 |
25 | // isHealthy checks all the records were updated successfully and returns an error if not.
26 | func isHealthy(ctx context.Context, db AllSelecter, resolver LookupIPer) (err error) {
27 | records := db.SelectAll()
28 | for _, record := range records {
29 | if record.Status == constants.FAIL {
30 | return fmt.Errorf("%w: %s", ErrRecordUpdateFailed, record.String())
31 | } else if record.Provider.Proxied() {
32 | continue
33 | }
34 |
35 | hostname := record.Provider.BuildDomainName()
36 |
37 | currentIP := record.History.GetCurrentIP()
38 | if !currentIP.IsValid() {
39 | return fmt.Errorf("%w: for hostname %s", ErrRecordIPNotSet, hostname)
40 | }
41 |
42 | lookedUpNetIPs, err := resolver.LookupIP(ctx, "ip", hostname)
43 | if err != nil {
44 | return err
45 | }
46 |
47 | found := false
48 | lookedUpIPsString := make([]string, len(lookedUpNetIPs))
49 | for i, netIP := range lookedUpNetIPs {
50 | var ip netip.Addr
51 | switch {
52 | case netIP == nil:
53 | case netIP.To4() != nil:
54 | ip = netip.AddrFrom4([4]byte(netIP.To4()))
55 | default: // IPv6
56 | ip = netip.AddrFrom16([16]byte(netIP.To16()))
57 | }
58 | if ip.Compare(currentIP) == 0 {
59 | found = true
60 | break
61 | }
62 | lookedUpIPsString[i] = ip.String()
63 | }
64 | if !found {
65 | return fmt.Errorf("%w: %s instead of %s for %s",
66 | ErrLookupMismatch, strings.Join(lookedUpIPsString, ","), currentIP, hostname)
67 | }
68 | }
69 | return nil
70 | }
71 |
--------------------------------------------------------------------------------
/internal/health/client.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net"
9 | "net/http"
10 | "time"
11 | )
12 |
13 | type Client struct {
14 | *http.Client
15 | }
16 |
17 | func NewClient() *Client {
18 | const timeout = 5 * time.Second
19 | return &Client{
20 | Client: &http.Client{Timeout: timeout},
21 | }
22 | }
23 |
24 | var ErrUnhealthy = errors.New("program is unhealthy")
25 |
26 | // Query sends an HTTP request to the other instance of
27 | // the program, and to its internal healthcheck server.
28 | func (c *Client) Query(ctx context.Context, listeningAddress string) error {
29 | _, port, err := net.SplitHostPort(listeningAddress)
30 | if err != nil {
31 | return fmt.Errorf("splitting host and port from address: %w", err)
32 | }
33 |
34 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port, nil)
35 | if err != nil {
36 | return err
37 | }
38 | resp, err := c.Do(req)
39 | if err != nil {
40 | return err
41 | }
42 | defer resp.Body.Close()
43 |
44 | if resp.StatusCode == http.StatusOK {
45 | return nil
46 | }
47 |
48 | b, err := io.ReadAll(resp.Body)
49 | if err != nil {
50 | return fmt.Errorf("reading body from response with status %s: %w", resp.Status, err)
51 | }
52 |
53 | return fmt.Errorf("%w: %s", ErrUnhealthy, string(b))
54 | }
55 |
--------------------------------------------------------------------------------
/internal/health/handler.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | )
7 |
8 | func newHandler(healthcheck func(context.Context) error) http.Handler {
9 | return &handler{
10 | healthcheck: healthcheck,
11 | }
12 | }
13 |
14 | type handler struct {
15 | healthcheck func(context.Context) error
16 | }
17 |
18 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
19 | if r.Method != http.MethodGet || (r.RequestURI != "" && r.RequestURI != "/") {
20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
21 | return
22 | }
23 | err := h.healthcheck(r.Context())
24 | if err != nil {
25 | http.Error(w, err.Error(), http.StatusInternalServerError)
26 | return
27 | }
28 | w.WriteHeader(http.StatusOK)
29 | }
30 |
--------------------------------------------------------------------------------
/internal/health/httpcheck.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | )
9 |
10 | var ErrHTTPStatusCodeNotOK = errors.New("status code is not OK")
11 |
12 | func CheckHTTP(ctx context.Context, client *http.Client) (err error) {
13 | const url = "https://github.com"
14 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
15 | if err != nil {
16 | return fmt.Errorf("creating request: %w", err)
17 | }
18 |
19 | response, err := client.Do(request)
20 | if err != nil {
21 | return fmt.Errorf("performing request: %w", err)
22 | }
23 | _ = response.Body.Close()
24 |
25 | if response.StatusCode != http.StatusOK {
26 | return fmt.Errorf("%w: %d", ErrHTTPStatusCodeNotOK, response.StatusCode)
27 | }
28 |
29 | return nil
30 | }
31 |
--------------------------------------------------------------------------------
/internal/health/interfaces.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 | "net"
6 |
7 | "github.com/qdm12/ddns-updater/internal/records"
8 | )
9 |
10 | type AllSelecter interface {
11 | SelectAll() (records []records.Record)
12 | }
13 |
14 | type LookupIPer interface {
15 | LookupIP(ctx context.Context, network, host string) (ips []net.IP, err error)
16 | }
17 |
18 | type Logger interface {
19 | Info(s string)
20 | Warn(s string)
21 | Error(s string)
22 | }
23 |
--------------------------------------------------------------------------------
/internal/health/server.go:
--------------------------------------------------------------------------------
1 | package health
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/qdm12/goservices/httpserver"
7 | )
8 |
9 | func NewServer(address string, logger Logger, healthcheck func(context.Context) error) (
10 | server *httpserver.Server, err error,
11 | ) {
12 | name := "health"
13 | return httpserver.New(httpserver.Settings{
14 | Handler: newHandler(healthcheck),
15 | Name: &name,
16 | Address: &address,
17 | Logger: logger,
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/internal/healthchecksio/healthchecksio.go:
--------------------------------------------------------------------------------
1 | package healthchecksio
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | )
9 |
10 | // New creates a new healthchecks.io client.
11 | // If passed an empty uuid string, it acts as no-op implementation.
12 | func New(httpClient *http.Client, baseURL, uuid string) *Client {
13 | return &Client{
14 | httpClient: httpClient,
15 | baseURL: baseURL,
16 | uuid: uuid,
17 | }
18 | }
19 |
20 | type Client struct {
21 | httpClient *http.Client
22 | baseURL string
23 | uuid string
24 | }
25 |
26 | var ErrStatusCode = errors.New("bad status code")
27 |
28 | type State string
29 |
30 | const (
31 | Ok State = "ok"
32 | Start State = "start"
33 | Fail State = "fail"
34 | Exit0 State = "0"
35 | Exit1 State = "1"
36 | )
37 |
38 | func (c *Client) Ping(ctx context.Context, state State) (err error) {
39 | if c.uuid == "" {
40 | return nil
41 | }
42 |
43 | url := c.baseURL + "/" + c.uuid
44 | if state != Ok {
45 | url += "/" + string(state)
46 | }
47 |
48 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
49 | if err != nil {
50 | return fmt.Errorf("creating request: %w", err)
51 | }
52 |
53 | response, err := c.httpClient.Do(request)
54 | if err != nil {
55 | return fmt.Errorf("doing http request: %w", err)
56 | }
57 |
58 | switch response.StatusCode {
59 | case http.StatusOK:
60 | default:
61 | return fmt.Errorf("%w: %d %s", ErrStatusCode, response.StatusCode, response.Status)
62 | }
63 |
64 | err = response.Body.Close()
65 | if err != nil {
66 | return fmt.Errorf("closing response body: %w", err)
67 | }
68 |
69 | return nil
70 | }
71 |
--------------------------------------------------------------------------------
/internal/models/alias.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type (
4 | // Provider is a possible DNS provider.
5 | Provider string
6 | // Status is the record config status.
7 | Status string
8 | )
9 |
--------------------------------------------------------------------------------
/internal/models/build.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type BuildInformation struct {
4 | Version string `json:"version"`
5 | Commit string `json:"commit"`
6 | Created string `json:"buildDate"`
7 | }
8 |
9 | func (b BuildInformation) VersionString() string {
10 | if b.Version != "latest" {
11 | return b.Version
12 | }
13 | const commitShortHashLength = 7
14 | if len(b.Commit) != commitShortHashLength {
15 | return "latest"
16 | }
17 | return b.Version + "-" + b.Commit[:7]
18 | }
19 |
--------------------------------------------------------------------------------
/internal/models/history_test.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "net/netip"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func Test_GetPreviousIPs(t *testing.T) {
12 | t.Parallel()
13 | testCases := map[string]struct {
14 | h History
15 | previousIPs []netip.Addr
16 | }{
17 | "empty_history": {
18 | h: History{},
19 | },
20 | "single_event": {
21 | h: History{
22 | {IP: netip.MustParseAddr("1.2.3.4")},
23 | },
24 | },
25 | "two_events": {
26 | h: History{
27 | {IP: netip.MustParseAddr("1.2.3.4")},
28 | {IP: netip.MustParseAddr("5.6.7.8")}, // last one
29 | },
30 | previousIPs: []netip.Addr{
31 | netip.MustParseAddr("1.2.3.4"),
32 | },
33 | },
34 | "three_events": {
35 | h: History{
36 | {IP: netip.MustParseAddr("1.2.3.4")},
37 | {IP: netip.MustParseAddr("5.6.7.8")},
38 | {IP: netip.MustParseAddr("9.6.7.8")}, // last one
39 | },
40 | previousIPs: []netip.Addr{
41 | netip.MustParseAddr("5.6.7.8"),
42 | netip.MustParseAddr("1.2.3.4"),
43 | },
44 | },
45 | }
46 | for name, testCase := range testCases {
47 | t.Run(name, func(t *testing.T) {
48 | t.Parallel()
49 | previousIPs := testCase.h.GetPreviousIPs()
50 | assert.Equal(t, testCase.previousIPs, previousIPs)
51 | })
52 | }
53 | }
54 |
55 | func Test_GetDurationSinceSuccess(t *testing.T) {
56 | t.Parallel()
57 | tests := map[string]struct {
58 | h History
59 | s string
60 | }{
61 | "empty history": {
62 | h: History{},
63 | s: "N/A",
64 | },
65 | "single event": {
66 | h: History{{}},
67 | s: "106751d",
68 | },
69 | "two events": {
70 | h: History{{}, {}},
71 | s: "106751d",
72 | },
73 | }
74 | for name, tc := range tests {
75 | t.Run(name, func(t *testing.T) {
76 | t.Parallel()
77 | now, _ := time.Parse("2006-01-02", "2000-01-01")
78 | s := tc.h.GetDurationSinceSuccess(now)
79 | assert.Equal(t, tc.s, s)
80 | })
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/internal/models/html.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // HTMLData is a list of HTML fields to be rendered.
4 | // It is exported so that the HTML template engine can render it.
5 | type HTMLData struct {
6 | Rows []HTMLRow
7 | }
8 |
9 | // HTMLRow contains HTML fields to be rendered
10 | // It is exported so that the HTML template engine can render it.
11 | type HTMLRow struct {
12 | Domain string
13 | Owner string
14 | Provider string
15 | IPVersion string
16 | Status string
17 | CurrentIP string
18 | PreviousIPs string
19 | }
20 |
--------------------------------------------------------------------------------
/internal/models/ipmethod.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // IPMethod is a method to obtain your public IP address.
4 | type IPMethod struct {
5 | Name string
6 | URL string
7 | IPv4 bool
8 | IPv6 bool
9 | }
10 |
--------------------------------------------------------------------------------
/internal/noop/service.go:
--------------------------------------------------------------------------------
1 | package noop
2 |
3 | import "context"
4 |
5 | type Service struct {
6 | name string
7 | }
8 |
9 | func New(name string) *Service {
10 | return &Service{
11 | name: name,
12 | }
13 | }
14 |
15 | func (s *Service) String() string {
16 | return s.name + " (no-op)"
17 | }
18 |
19 | func (s *Service) Start(_ context.Context) (_ <-chan error, _ error) {
20 | return nil, nil //nolint:nilnil
21 | }
22 |
23 | func (s *Service) Stop() (stopErr error) {
24 | return nil
25 | }
26 |
--------------------------------------------------------------------------------
/internal/params/reader.go:
--------------------------------------------------------------------------------
1 | package params
2 |
3 | import (
4 | "io/fs"
5 | "os"
6 | )
7 |
8 | type Reader struct {
9 | logger Logger
10 | readFile func(filename string) ([]byte, error)
11 | writeFile func(filename string, data []byte, perm fs.FileMode) (err error)
12 | }
13 |
14 | type Logger interface {
15 | Info(s string)
16 | Debug(s string)
17 | }
18 |
19 | func NewReader(logger Logger) *Reader {
20 | return &Reader{
21 | logger: logger,
22 | readFile: os.ReadFile,
23 | writeFile: os.WriteFile,
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/internal/params/retro.go:
--------------------------------------------------------------------------------
1 | package params
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/netip"
7 | "os"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func getRetroIPv6Suffix() (suffix netip.Prefix, err error) {
13 | prefixBitsString := os.Getenv("IPV6_PREFIX")
14 | if prefixBitsString == "" {
15 | return netip.Prefix{}, nil
16 | }
17 |
18 | return makeIPv6Suffix(prefixBitsString)
19 | }
20 |
21 | var ErrIPv6PrefixFormat = errors.New("IPv6 prefix format is incorrect")
22 |
23 | func makeIPv6Suffix(prefixBitsString string) (suffix netip.Prefix, err error) {
24 | prefixBitsString = strings.TrimPrefix(prefixBitsString, "/")
25 |
26 | const base, bitSize = 10, 8
27 | prefixBits, err := strconv.ParseUint(prefixBitsString, base, bitSize)
28 | if err != nil {
29 | return netip.Prefix{}, fmt.Errorf("%w: cannot parse %q as uint8",
30 | ErrIPv6PrefixFormat, prefixBitsString)
31 | }
32 |
33 | const ipv6Bits = 128
34 | if prefixBits > ipv6Bits {
35 | return netip.Prefix{}, fmt.Errorf("%w: %d bits cannot be greater than %d",
36 | ErrIPv6PrefixFormat, prefixBits, ipv6Bits)
37 | }
38 |
39 | suffixBits := ipv6Bits - int(prefixBits)
40 | suffix = netip.PrefixFrom(netip.AddrFrom16([16]byte{}), suffixBits)
41 |
42 | return suffix, nil
43 | }
44 |
--------------------------------------------------------------------------------
/internal/params/retro_test.go:
--------------------------------------------------------------------------------
1 | package params
2 |
3 | import (
4 | "errors"
5 | "net/netip"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func Test_makeIPv6Suffix(t *testing.T) {
13 | t.Parallel()
14 |
15 | testCases := map[string]struct {
16 | prefixBitsString string
17 | suffix netip.Prefix
18 | errWrapped error
19 | errMessage string
20 | }{
21 | "empty": {
22 | errWrapped: errors.New(`IPv6 prefix format is incorrect: ` +
23 | `cannot parse "" as uint8`),
24 | },
25 | "malformed": {
26 | prefixBitsString: "malformed",
27 | errWrapped: errors.New(`IPv6 prefix format is incorrect: ` +
28 | `cannot parse "malformed" as uint8`),
29 | },
30 | "with_leading_slash": {
31 | prefixBitsString: "/78",
32 | suffix: netip.MustParsePrefix("::/50"),
33 | },
34 | "without_leading_slash": {
35 | prefixBitsString: "78",
36 | suffix: netip.MustParsePrefix("::/50"),
37 | },
38 | "full_IPv6_mask": {
39 | prefixBitsString: "/128",
40 | suffix: netip.MustParsePrefix("::/0"),
41 | },
42 | "zero_IPv6_mask": {
43 | prefixBitsString: "/0",
44 | suffix: netip.MustParsePrefix("::/128"),
45 | },
46 | }
47 |
48 | for name, testCase := range testCases {
49 | t.Run(name, func(t *testing.T) {
50 | t.Parallel()
51 |
52 | suffix, err := makeIPv6Suffix(testCase.prefixBitsString)
53 |
54 | if testCase.errWrapped != nil {
55 | require.Error(t, err)
56 | assert.Equal(t, testCase.errWrapped.Error(), err.Error())
57 | } else {
58 | assert.NoError(t, err)
59 | }
60 | assert.Equal(t, testCase.suffix, suffix)
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/internal/persistence/json/models.go:
--------------------------------------------------------------------------------
1 | package json
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/qdm12/ddns-updater/internal/models"
7 | )
8 |
9 | type dataModel struct {
10 | Records []record `json:"records"`
11 | }
12 |
13 | type record struct {
14 | Domain string `json:"domain"`
15 | // Host is kept for retro-compatibility and is replaced by Owner.
16 | Host string `json:"host,omitempty"`
17 | Owner string `json:"owner"`
18 | Events []models.HistoryEvent `json:"ips"`
19 | }
20 |
21 | func (r record) String() string {
22 | b, err := json.Marshal(r)
23 | if err != nil {
24 | panic(err)
25 | }
26 |
27 | return string(b)
28 | }
29 |
--------------------------------------------------------------------------------
/internal/provider/constants/dyndns.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | const (
4 | Badauth = "badauth"
5 | Success = "success"
6 | Nohost = "nohost"
7 | Notfqdn = "notfqdn"
8 | Badagent = "badagent"
9 | Abuse = "abuse"
10 | Nineoneone = "911"
11 | )
12 |
--------------------------------------------------------------------------------
/internal/provider/constants/recordtypes.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | const (
4 | A = "A"
5 | AAAA = "AAAA"
6 | CNAME = "CNAME"
7 | ALIAS = "ALIAS"
8 | )
9 |
--------------------------------------------------------------------------------
/internal/provider/headers/headers.go:
--------------------------------------------------------------------------------
1 | package headers
2 |
3 | import "net/http"
4 |
5 | func SetUserAgent(request *http.Request) {
6 | request.Header.Set("User-Agent", "DDNS-Updater quentin.mcgaw@gmail.com")
7 | }
8 |
9 | func SetContentType(request *http.Request, contentType string) {
10 | request.Header.Set("Content-Type", contentType)
11 | }
12 |
13 | func SetAccept(request *http.Request, acceptContent string) {
14 | request.Header.Set("Accept", acceptContent)
15 | }
16 |
17 | func SetAuthBearer(request *http.Request, token string) {
18 | request.Header.Set("Authorization", "Bearer "+token)
19 | }
20 |
21 | func SetAuthSSOKey(request *http.Request, key, secret string) {
22 | request.Header.Set("Authorization", "sso-key "+key+":"+secret)
23 | }
24 |
25 | func SetOauth(request *http.Request, value string) {
26 | request.Header.Set("Oauth", value)
27 | }
28 |
29 | func SetXFilter(request *http.Request, value string) {
30 | request.Header.Set("X-Filter", value)
31 | }
32 |
33 | func SetXAuthUsername(request *http.Request, value string) {
34 | request.Header.Set("X-Auth-Username", value)
35 | }
36 |
37 | func SetXAuthPassword(request *http.Request, value string) {
38 | request.Header.Set("X-Auth-Password", value)
39 | }
40 |
41 | func SetXAPIKey(request *http.Request, value string) {
42 | request.Header.Set("X-Api-Key", value)
43 | }
44 |
--------------------------------------------------------------------------------
/internal/provider/providers/aliyun/auth.go:
--------------------------------------------------------------------------------
1 | package aliyun
2 |
3 | //nolint:gosec
4 | import (
5 | "crypto/hmac"
6 | "crypto/sha1"
7 | "encoding/base64"
8 | "net/url"
9 | "sort"
10 | "strings"
11 | )
12 |
13 | func sign(method string, urlValues url.Values, accessKeySecret string) {
14 | sortedParams := make(sort.StringSlice, 0, len(urlValues))
15 | for key, values := range urlValues {
16 | s := url.QueryEscape(key) + "=" + url.QueryEscape(values[0])
17 | sortedParams = append(sortedParams, s)
18 | }
19 | sortedParams.Sort()
20 |
21 | stringToSign := strings.ToUpper(method) + "&%2F&" +
22 | url.QueryEscape(strings.Join(sortedParams, "&"))
23 |
24 | key := []byte(accessKeySecret + "&")
25 | hmac := hmac.New(sha1.New, key)
26 | _, _ = hmac.Write([]byte(stringToSign))
27 | signedBytes := hmac.Sum(nil)
28 | signature := base64.StdEncoding.EncodeToString(signedBytes)
29 | urlValues.Set("Signature", signature)
30 | }
31 |
--------------------------------------------------------------------------------
/internal/provider/providers/aliyun/common.go:
--------------------------------------------------------------------------------
1 | package aliyun
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/binary"
6 | "net/http"
7 | "net/url"
8 | "strconv"
9 | "time"
10 |
11 | "github.com/qdm12/ddns-updater/internal/provider/headers"
12 | )
13 |
14 | func newURLValues(accessKeyID string) (values url.Values) {
15 | randBytes := make([]byte, 8) //nolint:mnd
16 | _, _ = rand.Read(randBytes)
17 | randInt64 := int64(binary.BigEndian.Uint64(randBytes)) //nolint:gosec
18 |
19 | values = make(url.Values)
20 | values.Set("AccessKeyId", accessKeyID)
21 | values.Set("Format", "JSON")
22 | values.Set("Version", "2015-01-09")
23 | values.Set("SignatureMethod", "HMAC-SHA1")
24 | values.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
25 | values.Set("SignatureVersion", "1.0")
26 | values.Set("SignatureNonce", strconv.FormatInt(randInt64, 10))
27 | return values
28 | }
29 |
30 | func setHeaders(request *http.Request) {
31 | headers.SetUserAgent(request)
32 | headers.SetAccept(request, "application/json")
33 | }
34 |
--------------------------------------------------------------------------------
/internal/provider/providers/aliyun/create.go:
--------------------------------------------------------------------------------
1 | package aliyun
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/netip"
9 | "net/url"
10 |
11 | "github.com/qdm12/ddns-updater/internal/provider/constants"
12 | "github.com/qdm12/ddns-updater/internal/provider/errors"
13 | "github.com/qdm12/ddns-updater/internal/provider/utils"
14 | )
15 |
16 | func (p *Provider) createRecord(ctx context.Context,
17 | client *http.Client, ip netip.Addr,
18 | ) (recordID string, err error) {
19 | recordType := constants.A
20 | if ip.Is6() {
21 | recordType = constants.AAAA
22 | }
23 |
24 | u := &url.URL{
25 | Scheme: "https",
26 | Host: "alidns.aliyuncs.com",
27 | }
28 | values := newURLValues(p.accessKeyID)
29 | values.Set("Action", "AddDomainRecord")
30 | values.Set("DomainName", p.domain)
31 | values.Set("RR", p.owner)
32 | values.Set("Type", recordType)
33 | values.Set("Value", ip.String())
34 |
35 | sign(http.MethodGet, values, p.accessSecret)
36 |
37 | u.RawQuery = values.Encode()
38 |
39 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
40 | if err != nil {
41 | return "", fmt.Errorf("creating http request: %w", err)
42 | }
43 | setHeaders(request)
44 |
45 | response, err := client.Do(request)
46 | if err != nil {
47 | return "", fmt.Errorf("doing HTTP request: %w", err)
48 | }
49 | defer response.Body.Close()
50 |
51 | if response.StatusCode != http.StatusOK {
52 | return "", fmt.Errorf("%w: %d: %s",
53 | errors.ErrHTTPStatusNotValid, response.StatusCode,
54 | utils.BodyToSingleLine(response.Body))
55 | }
56 |
57 | var data struct {
58 | RecordID string `json:"RecordId"`
59 | }
60 | decoder := json.NewDecoder(response.Body)
61 | err = decoder.Decode(&data)
62 | if err != nil {
63 | return "", fmt.Errorf("json decoding response body: %w", err)
64 | }
65 |
66 | return data.RecordID, nil
67 | }
68 |
--------------------------------------------------------------------------------
/internal/provider/providers/aliyun/update.go:
--------------------------------------------------------------------------------
1 | package aliyun
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "net/netip"
8 | "net/url"
9 |
10 | "github.com/qdm12/ddns-updater/internal/provider/constants"
11 | "github.com/qdm12/ddns-updater/internal/provider/errors"
12 | "github.com/qdm12/ddns-updater/internal/provider/utils"
13 | )
14 |
15 | func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
16 | recordID string, ip netip.Addr,
17 | ) (err error) {
18 | recordType := constants.A
19 | if ip.Is6() {
20 | recordType = constants.AAAA
21 | }
22 |
23 | u := &url.URL{
24 | Scheme: "https",
25 | Host: "alidns.aliyuncs.com",
26 | }
27 | values := newURLValues(p.accessKeyID)
28 | values.Set("Action", "UpdateDomainRecord")
29 | values.Set("RecordId", recordID)
30 | values.Set("RR", p.owner)
31 | values.Set("Type", recordType)
32 | values.Set("Value", ip.String())
33 |
34 | sign(http.MethodGet, values, p.accessSecret)
35 |
36 | u.RawQuery = values.Encode()
37 |
38 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
39 | if err != nil {
40 | return fmt.Errorf("creating http request: %w", err)
41 | }
42 | setHeaders(request)
43 |
44 | response, err := client.Do(request)
45 | if err != nil {
46 | return err
47 | }
48 | defer response.Body.Close()
49 |
50 | switch response.StatusCode {
51 | case http.StatusOK:
52 | default:
53 | return fmt.Errorf("%w: %d: %s",
54 | errors.ErrHTTPStatusNotValid, response.StatusCode,
55 | utils.BodyToSingleLine(response.Body))
56 | }
57 |
58 | return nil
59 | }
60 |
--------------------------------------------------------------------------------
/internal/provider/providers/dnsomatic/provider_test.go:
--------------------------------------------------------------------------------
1 | package dnsomatic
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/qdm12/ddns-updater/internal/provider/errors"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func Test_validateSettings(t *testing.T) {
11 | t.Parallel()
12 |
13 | testCases := map[string]struct {
14 | domain string
15 | username string
16 | password string
17 | errWrapped error
18 | errMessage string
19 | }{
20 | "empty_username": {
21 | domain: "domain.com",
22 | password: "password",
23 | errWrapped: errors.ErrUsernameNotSet,
24 | errMessage: `username is not set`,
25 | },
26 | }
27 |
28 | for name, testCase := range testCases {
29 | t.Run(name, func(t *testing.T) {
30 | t.Parallel()
31 |
32 | err := validateSettings(testCase.domain, testCase.username, testCase.password)
33 |
34 | assert.ErrorIs(t, err, testCase.errWrapped)
35 | if testCase.errWrapped != nil {
36 | assert.EqualError(t, err, testCase.errMessage)
37 | }
38 | })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/internal/provider/providers/dreamhost/headers.go:
--------------------------------------------------------------------------------
1 | package dreamhost
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/internal/provider/headers"
7 | )
8 |
9 | func setHeaders(request *http.Request) {
10 | headers.SetUserAgent(request)
11 | headers.SetAccept(request, "application/json")
12 | }
13 |
--------------------------------------------------------------------------------
/internal/provider/providers/gcp/oauth2.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 |
8 | "golang.org/x/oauth2"
9 | "golang.org/x/oauth2/google"
10 | )
11 |
12 | func createOauth2Client(ctx context.Context, client *http.Client, credentialsJSON []byte) (
13 | oauth2Client *http.Client, err error,
14 | ) {
15 | scopes := []string{
16 | "https://www.googleapis.com/auth/cloud-platform",
17 | "https://www.googleapis.com/auth/cloud-platform.read-only",
18 | "https://www.googleapis.com/auth/ndev.clouddns.readonly",
19 | "https://www.googleapis.com/auth/ndev.clouddns.readwrite",
20 | }
21 | credentials, err := google.CredentialsFromJSON(ctx, credentialsJSON, scopes...)
22 | if err != nil {
23 | return nil, fmt.Errorf("creating Google credentials: %w", err)
24 | }
25 | oauth2Client = &http.Client{
26 | Timeout: client.Timeout,
27 | Transport: &oauth2.Transport{
28 | Base: client.Transport,
29 | Source: oauth2.ReuseTokenSource(nil, credentials.TokenSource),
30 | },
31 | }
32 |
33 | return oauth2Client, nil
34 | }
35 |
--------------------------------------------------------------------------------
/internal/provider/providers/gcp/update.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/netip"
9 |
10 | "github.com/qdm12/ddns-updater/internal/provider/constants"
11 | ddnserrors "github.com/qdm12/ddns-updater/internal/provider/errors"
12 | )
13 |
14 | func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
15 | recordType := constants.A
16 | if ip.Is6() {
17 | recordType = constants.AAAA
18 | }
19 |
20 | client, err = createOauth2Client(ctx, client, p.credentials)
21 | if err != nil {
22 | return netip.Addr{}, fmt.Errorf("creating OAuth2 client: %w", err)
23 | }
24 |
25 | fqdn := fmt.Sprintf("%s.%s.", p.owner, p.domain)
26 |
27 | recordResourceSet, err := p.getRRSet(ctx, client, fqdn, recordType)
28 | rrSetFound := true
29 | if err != nil {
30 | if errors.Is(err, ddnserrors.ErrRecordResourceSetNotFound) {
31 | rrSetFound = false // not finding the record is fine
32 | } else {
33 | return netip.Addr{}, fmt.Errorf("getting record resource set: %w", err)
34 | }
35 | }
36 |
37 | if !rrSetFound {
38 | err = p.createRRSet(ctx, client, fqdn, recordType, ip)
39 | if err != nil {
40 | return netip.Addr{}, fmt.Errorf("creating record: %w", err)
41 | }
42 | return ip, nil
43 | }
44 |
45 | for _, rrdata := range recordResourceSet.Rrdatas {
46 | if rrdata == ip.String() {
47 | // already up to date
48 | return ip, nil
49 | }
50 | }
51 |
52 | err = p.patchRRSet(ctx, client, fqdn, recordType, ip)
53 | if err != nil {
54 | return netip.Addr{}, fmt.Errorf("updating record: %w", err)
55 | }
56 |
57 | return ip, nil
58 | }
59 |
--------------------------------------------------------------------------------
/internal/provider/providers/hetzner/common.go:
--------------------------------------------------------------------------------
1 | package hetzner
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/internal/provider/headers"
7 | )
8 |
9 | func (p *Provider) setHeaders(request *http.Request) {
10 | headers.SetUserAgent(request)
11 | headers.SetContentType(request, "application/json")
12 | headers.SetAccept(request, "application/json")
13 | request.Header.Set("Auth-Api-Token", p.token)
14 | }
15 |
--------------------------------------------------------------------------------
/internal/provider/providers/ionos/api.go:
--------------------------------------------------------------------------------
1 | package ionos
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/internal/provider/headers"
7 | )
8 |
9 | type apiZone struct {
10 | ID string `json:"id"`
11 | Name string `json:"name"`
12 | }
13 |
14 | type apiRecord struct {
15 | ID string `json:"id"`
16 | Name string `json:"name"`
17 | RootName string `json:"rootName"`
18 | Type string `json:"type"`
19 | Content string `json:"content"`
20 | TTL uint32 `json:"ttl"`
21 | Prio uint32 `json:"prio"`
22 | Disabled bool `json:"disabled"`
23 | }
24 |
25 | func (p *Provider) setHeaders(request *http.Request) {
26 | headers.SetUserAgent(request)
27 | headers.SetAccept(request, "application/json")
28 | headers.SetXAPIKey(request, p.apiKey)
29 | switch request.Method {
30 | case http.MethodPost, http.MethodPut:
31 | headers.SetContentType(request, "application/json")
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/internal/provider/providers/namecom/createrecord.go:
--------------------------------------------------------------------------------
1 | package namecom
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "net/netip"
10 | "net/url"
11 |
12 | "github.com/qdm12/ddns-updater/internal/provider/constants"
13 | "github.com/qdm12/ddns-updater/internal/provider/headers"
14 | )
15 |
16 | func (p *Provider) createRecord(ctx context.Context, client *http.Client,
17 | ip netip.Addr,
18 | ) (err error) {
19 | recordType := constants.A
20 | if ip.Is6() {
21 | recordType = constants.AAAA
22 | }
23 |
24 | u := &url.URL{
25 | Scheme: "https",
26 | Host: "api.name.com",
27 | Path: fmt.Sprintf("/v4/domains/%s/records", p.domain),
28 | User: url.UserPassword(p.username, p.token),
29 | }
30 |
31 | postRecordsParams := struct {
32 | Host string `json:"host"`
33 | Type string `json:"type"`
34 | Answer string `json:"answer"`
35 | TTL *uint32 `json:"ttl,omitempty"`
36 | }{
37 | Host: p.owner,
38 | Type: recordType,
39 | Answer: ip.String(),
40 | TTL: p.ttl,
41 | }
42 |
43 | bodyBytes, err := json.Marshal(postRecordsParams)
44 | if err != nil {
45 | return fmt.Errorf("json encoding request data: %w", err)
46 | }
47 |
48 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
49 | if err != nil {
50 | return fmt.Errorf("creating http request: %w", err)
51 | }
52 | setHeaders(request)
53 | headers.SetContentType(request, "application/json")
54 |
55 | response, err := client.Do(request)
56 | if err != nil {
57 | return fmt.Errorf("doing HTTP request: %w", err)
58 | }
59 | defer response.Body.Close()
60 |
61 | switch response.StatusCode {
62 | case http.StatusOK, http.StatusCreated:
63 | return verifySuccessResponseBody(response.Body, ip)
64 | default:
65 | return parseErrorResponse(response)
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/internal/provider/providers/namecom/getrecord.go:
--------------------------------------------------------------------------------
1 | package namecom
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 |
10 | "github.com/qdm12/ddns-updater/internal/provider/errors"
11 | )
12 |
13 | func (p *Provider) getRecordID(ctx context.Context, client *http.Client,
14 | recordType string,
15 | ) (recordID int, err error) {
16 | u := &url.URL{
17 | Scheme: "https",
18 | Host: "api.name.com",
19 | Path: fmt.Sprintf("/v4/domains/%s/records", p.domain),
20 | User: url.UserPassword(p.username, p.token),
21 | }
22 |
23 | // by default GET request will return 1000 records.
24 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
25 | if err != nil {
26 | return 0, fmt.Errorf("creating http request: %w", err)
27 | }
28 | setHeaders(request)
29 |
30 | response, err := client.Do(request)
31 | if err != nil {
32 | return 0, fmt.Errorf("doing http request: %w", err)
33 | }
34 | defer response.Body.Close()
35 |
36 | switch response.StatusCode {
37 | case http.StatusOK:
38 | case http.StatusNotFound:
39 | return 0, fmt.Errorf("%w", errors.ErrDomainNotFound)
40 | default:
41 | return 0, parseErrorResponse(response)
42 | }
43 |
44 | decoder := json.NewDecoder(response.Body)
45 | var data struct {
46 | Records []struct {
47 | RecordID int `json:"id"`
48 | Host string `json:"host"`
49 | Type string `json:"type"`
50 | } `json:"records"`
51 | }
52 | err = decoder.Decode(&data)
53 | if err != nil {
54 | return 0, fmt.Errorf("json decoding response body: %w", err)
55 | }
56 |
57 | for _, record := range data.Records {
58 | if record.Host == "" {
59 | record.Host = "@"
60 | }
61 | if record.Host == p.owner && record.Type == recordType {
62 | return record.RecordID, nil
63 | }
64 | }
65 |
66 | return 0, fmt.Errorf("%w: in %d record(s)",
67 | errors.ErrRecordNotFound, len(data.Records))
68 | }
69 |
--------------------------------------------------------------------------------
/internal/provider/providers/namecom/headers.go:
--------------------------------------------------------------------------------
1 | package namecom
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/internal/provider/headers"
7 | )
8 |
9 | func setHeaders(request *http.Request) {
10 | headers.SetAccept(request, "application/json")
11 | headers.SetUserAgent(request)
12 | }
13 |
--------------------------------------------------------------------------------
/internal/provider/providers/namecom/updaterecord.go:
--------------------------------------------------------------------------------
1 | package namecom
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "net/netip"
10 | "net/url"
11 |
12 | "github.com/qdm12/ddns-updater/internal/provider/constants"
13 | "github.com/qdm12/ddns-updater/internal/provider/headers"
14 | )
15 |
16 | func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
17 | recordID int, ip netip.Addr,
18 | ) (err error) {
19 | recordType := constants.A
20 | if ip.Is6() {
21 | recordType = constants.AAAA
22 | }
23 |
24 | u := &url.URL{
25 | Scheme: "https",
26 | Host: "api.name.com",
27 | Path: fmt.Sprintf("/v4/domains/%s/records/%d", p.domain, recordID),
28 | User: url.UserPassword(p.username, p.token),
29 | }
30 |
31 | host := ""
32 | if p.owner != "@" {
33 | host = p.owner
34 | }
35 | postRecordsParams := struct {
36 | Host string `json:"host"`
37 | Type string `json:"type"`
38 | Answer string `json:"answer"`
39 | TTL *uint32 `json:"ttl,omitempty"`
40 | }{
41 | Host: host,
42 | Type: recordType,
43 | Answer: ip.String(),
44 | TTL: p.ttl,
45 | }
46 |
47 | bodyBytes, err := json.Marshal(postRecordsParams)
48 | if err != nil {
49 | return fmt.Errorf("json encoding request data: %w", err)
50 | }
51 |
52 | request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bytes.NewBuffer(bodyBytes))
53 | if err != nil {
54 | return fmt.Errorf("creating http request: %w", err)
55 | }
56 | setHeaders(request)
57 | headers.SetContentType(request, "application/json")
58 |
59 | response, err := client.Do(request)
60 | if err != nil {
61 | return fmt.Errorf("doing HTTP request: %w", err)
62 | }
63 | defer response.Body.Close()
64 |
65 | switch response.StatusCode {
66 | case http.StatusOK, http.StatusCreated:
67 | return verifySuccessResponseBody(response.Body, ip)
68 | default:
69 | return parseErrorResponse(response)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/internal/provider/providers/netcup/info.go:
--------------------------------------------------------------------------------
1 | package netcup
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | )
7 |
8 | func (p *Provider) infoDNSRecords(ctx context.Context, client *http.Client,
9 | session string,
10 | ) (recordSet dnsRecordSet, err error) {
11 | type jsonParams struct {
12 | APIKey string `json:"apikey"`
13 | APISessionID string `json:"apisessionid"`
14 | CustomerNumber string `json:"customernumber"`
15 | DomainName string `json:"domainname"`
16 | }
17 |
18 | type jsonRequest struct {
19 | Action string `json:"action"`
20 | Param jsonParams `json:"param"`
21 | }
22 |
23 | request := jsonRequest{
24 | Action: "infoDnsRecords",
25 | Param: jsonParams{
26 | APIKey: p.apiKey,
27 | APISessionID: session,
28 | CustomerNumber: p.customerNumber,
29 | DomainName: p.domain,
30 | },
31 | }
32 |
33 | err = doJSONHTTP(ctx, client, request, &recordSet)
34 | return recordSet, err
35 | }
36 |
--------------------------------------------------------------------------------
/internal/provider/providers/netcup/login.go:
--------------------------------------------------------------------------------
1 | package netcup
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/qdm12/ddns-updater/internal/provider/errors"
9 | )
10 |
11 | func (p *Provider) login(ctx context.Context, client *http.Client) (
12 | session string, err error,
13 | ) {
14 | type jsonParams struct {
15 | APIKey string `json:"apikey"`
16 | APIPassword string `json:"apipassword"`
17 | CustomerNumber string `json:"customernumber"`
18 | }
19 |
20 | type jsonRequest struct {
21 | Action string `json:"action"`
22 | Param jsonParams `json:"param"`
23 | }
24 |
25 | requestBody := jsonRequest{
26 | Action: "login",
27 | Param: jsonParams{
28 | APIKey: p.apiKey,
29 | APIPassword: p.password,
30 | CustomerNumber: p.customerNumber,
31 | },
32 | }
33 |
34 | var responseData struct {
35 | Session string `json:"apisessionid"`
36 | }
37 |
38 | err = doJSONHTTP(ctx, client, requestBody, &responseData)
39 | if err != nil {
40 | return "", err
41 | }
42 |
43 | session = responseData.Session
44 |
45 | if session == "" {
46 | return "", fmt.Errorf("%w", errors.ErrSessionIsEmpty)
47 | }
48 |
49 | return session, nil
50 | }
51 |
--------------------------------------------------------------------------------
/internal/provider/providers/netcup/models.go:
--------------------------------------------------------------------------------
1 | package netcup
2 |
3 | type dnsRecord struct {
4 | ID string `json:"id"`
5 | Destination string `json:"destination"`
6 | Hostname string `json:"hostname"`
7 | Priority string `json:"priority"`
8 | State string `json:"state"`
9 | Type string `json:"type"`
10 | }
11 |
12 | type dnsRecordSet struct {
13 | DNSRecords []dnsRecord `json:"dnsrecords"`
14 | }
15 |
--------------------------------------------------------------------------------
/internal/provider/providers/netcup/update.go:
--------------------------------------------------------------------------------
1 | package netcup
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "net/netip"
8 |
9 | "github.com/qdm12/ddns-updater/internal/provider/constants"
10 | )
11 |
12 | func (p *Provider) getRecordToUpdate(ctx context.Context,
13 | client *http.Client, session string, ip netip.Addr) (
14 | record dnsRecord, err error,
15 | ) {
16 | recordSet, err := p.infoDNSRecords(ctx, client, session)
17 | if err != nil {
18 | return record, fmt.Errorf("getting DNS records: %w", err)
19 | }
20 |
21 | recordType := constants.A
22 | if ip.Is6() {
23 | recordType = constants.AAAA
24 | }
25 |
26 | for _, record = range recordSet.DNSRecords {
27 | if record.Hostname == p.owner && record.Type == recordType {
28 | record.Destination = ip.String()
29 | return record, nil
30 | }
31 | }
32 |
33 | return dnsRecord{
34 | Hostname: p.owner,
35 | Type: recordType,
36 | Destination: ip.String(),
37 | }, nil
38 | }
39 |
40 | func (p *Provider) updateDNSRecords(ctx context.Context, client *http.Client,
41 | session string, recordSet dnsRecordSet,
42 | ) (response dnsRecordSet, err error) {
43 | type jsonParam struct {
44 | APIKey string `json:"apikey"`
45 | APISessionID string `json:"apisessionid"`
46 | CustomerNumber string `json:"customernumber"`
47 | DomainName string `json:"domainname"`
48 | DNSRecordSet dnsRecordSet `json:"dnsrecordset"`
49 | }
50 | type jsonRequest struct {
51 | Action string `json:"action"`
52 | Param jsonParam `json:"param"`
53 | }
54 |
55 | request := jsonRequest{
56 | Action: "updateDnsRecords",
57 | Param: jsonParam{
58 | APIKey: p.apiKey,
59 | APISessionID: session,
60 | CustomerNumber: p.customerNumber,
61 | DomainName: p.domain,
62 | DNSRecordSet: recordSet,
63 | },
64 | }
65 |
66 | err = doJSONHTTP(ctx, client, request, &response)
67 | if err != nil {
68 | return response, fmt.Errorf("doing JSON HTTP exchange: %w", err)
69 | }
70 |
71 | return response, nil
72 | }
73 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/createrecord.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "net/url"
10 | )
11 |
12 | func (p *Provider) createRecord(ctx context.Context, client *http.Client,
13 | recordType, subdomain, ipStr string, timestamp int64,
14 | ) (err error) {
15 | u := url.URL{
16 | Scheme: p.apiURL.Scheme,
17 | Host: p.apiURL.Host,
18 | Path: fmt.Sprintf("%s/domain/zone/%s/record", p.apiURL.Path, p.domain),
19 | }
20 | postRecordsParams := struct {
21 | FieldType string `json:"fieldType"`
22 | SubDomain string `json:"subDomain"`
23 | Target string `json:"target"`
24 | }{
25 | FieldType: recordType,
26 | SubDomain: subdomain,
27 | Target: ipStr,
28 | }
29 | bodyBytes, err := json.Marshal(postRecordsParams)
30 | if err != nil {
31 | return fmt.Errorf("json encoding request data: %w", err)
32 | }
33 |
34 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewBuffer(bodyBytes))
35 | if err != nil {
36 | return fmt.Errorf("creating http request: %w", err)
37 | }
38 | request.Header.Add("Content-Type", "application/json;charset=utf-8")
39 | p.setHeaderCommon(request.Header)
40 | p.setHeaderAuth(request.Header, timestamp, request.Method, request.URL, bodyBytes)
41 |
42 | response, err := client.Do(request)
43 | if err != nil {
44 | return fmt.Errorf("doing http request: %w", err)
45 | }
46 |
47 | if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
48 | return extractAPIError(response)
49 | }
50 |
51 | _ = response.Body.Close()
52 |
53 | return nil
54 | }
55 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/endpoints.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/url"
7 | )
8 |
9 | var ErrEndpointUnknown = errors.New("short endpoint name unknown")
10 |
11 | func convertShortEndpoint(shortEndpoint string) (url *url.URL, err error) {
12 | switch shortEndpoint {
13 | case "", "ovh-eu": // default
14 | return url.Parse("https://eu.api.ovh.com/1.0")
15 | case "ovh-ca":
16 | return url.Parse("https://ca.api.ovh.com/1.0")
17 | case "ovh-us":
18 | return url.Parse("https://api.us.ovhcloud.com/1.0")
19 | case "kimsufi-eu":
20 | return url.Parse("https://eu.api.kimsufi.com/1.0")
21 | case "kimsufi-ca":
22 | return url.Parse("https://ca.api.kimsufi.com/1.0")
23 | case "soyoustart-eu":
24 | return url.Parse("https://eu.api.soyoustart.com/1.0")
25 | case "soyoustart-ca":
26 | return url.Parse("https://ca.api.soyoustart.com/1.0")
27 | }
28 | return nil, fmt.Errorf("%w: %s", ErrEndpointUnknown, shortEndpoint)
29 | }
30 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/errors.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 |
9 | "github.com/qdm12/ddns-updater/internal/provider/errors"
10 | )
11 |
12 | func extractAPIError(response *http.Response) (err error) {
13 | b, err := io.ReadAll(response.Body)
14 | if err != nil {
15 | _ = response.Body.Close()
16 | return fmt.Errorf("reading response body: %w", err)
17 | }
18 |
19 | var apiError struct {
20 | Message string `json:"Message"`
21 | }
22 | err = json.Unmarshal(b, &apiError)
23 | if err != nil {
24 | apiError.Message = string(b)
25 | }
26 | queryID := response.Header.Get("X-Ovh-Queryid")
27 |
28 | _ = response.Body.Close()
29 |
30 | return fmt.Errorf("%w: %s: %s: for query ID: %s",
31 | errors.ErrHTTPStatusNotValid, response.Status, apiError.Message, queryID)
32 | }
33 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/getrecords.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | )
10 |
11 | func (p *Provider) getRecords(ctx context.Context, client *http.Client,
12 | recordType, subdomain string, timestamp int64,
13 | ) (recordIDs []uint64, err error) {
14 | values := url.Values{}
15 | values.Set("fieldType", recordType)
16 | values.Set("subDomain", subdomain)
17 | u := url.URL{
18 | Scheme: p.apiURL.Scheme,
19 | Host: p.apiURL.Host,
20 | Path: fmt.Sprintf("%s/domain/zone/%s/record", p.apiURL.Path, p.domain),
21 | RawQuery: values.Encode(),
22 | }
23 |
24 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
25 | if err != nil {
26 | return nil, fmt.Errorf("creating http request: %w", err)
27 | }
28 | p.setHeaderCommon(request.Header)
29 | p.setHeaderAuth(request.Header, timestamp, request.Method, request.URL, nil)
30 |
31 | response, err := client.Do(request)
32 | if err != nil {
33 | return nil, fmt.Errorf("doing http request: %w", err)
34 | }
35 |
36 | if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
37 | return nil, extractAPIError(response)
38 | }
39 |
40 | decoder := json.NewDecoder(response.Body)
41 | err = decoder.Decode(&recordIDs)
42 | if err != nil {
43 | _ = response.Body.Close()
44 | return nil, fmt.Errorf("json decoding response body: %w", err)
45 | }
46 |
47 | _ = response.Body.Close()
48 |
49 | return recordIDs, nil
50 | }
51 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/headers.go:
--------------------------------------------------------------------------------
1 | //nolint:gosec
2 | package ovh
3 |
4 | import (
5 | "crypto/sha1"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "strconv"
10 | )
11 |
12 | func (p *Provider) setHeaderCommon(header http.Header) {
13 | header.Add("Accept", "application/json;charset=utf-8")
14 | header.Add("X-Ovh-Application", p.appKey)
15 | }
16 |
17 | func (p *Provider) setHeaderAuth(header http.Header, timestamp int64,
18 | httpMethod string, url *url.URL, body []byte,
19 | ) {
20 | header.Add("X-Ovh-Timestamp", strconv.Itoa(int(timestamp)))
21 | header.Add("X-Ovh-Consumer", p.consumerKey)
22 |
23 | sha1Sum := sha1.Sum([]byte(
24 | p.appSecret + "+" + p.consumerKey + "+" + httpMethod + "+" +
25 | url.String() + "+" + string(body) + "+" + strconv.Itoa(int(timestamp)),
26 | ))
27 |
28 | header.Add("X-Ovh-Signature", fmt.Sprintf("$1$%x", sha1Sum))
29 | }
30 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/refresh.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | )
9 |
10 | func (p *Provider) refresh(ctx context.Context, client *http.Client, timestamp int64) (err error) {
11 | u := url.URL{
12 | Scheme: p.apiURL.Scheme,
13 | Host: p.apiURL.Host,
14 | Path: fmt.Sprintf("%s/domain/zone/%s/refresh", p.apiURL.Path, p.domain),
15 | }
16 |
17 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
18 | if err != nil {
19 | return fmt.Errorf("creating http request: %w", err)
20 | }
21 | p.setHeaderCommon(request.Header)
22 | p.setHeaderAuth(request.Header, timestamp, request.Method, request.URL, nil)
23 |
24 | response, err := client.Do(request)
25 | if err != nil {
26 | return fmt.Errorf("doing http request: %w", err)
27 | }
28 |
29 | if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
30 | return extractAPIError(response)
31 | }
32 |
33 | _ = response.Body.Close()
34 |
35 | return nil
36 | }
37 |
--------------------------------------------------------------------------------
/internal/provider/providers/ovh/updaterecord.go:
--------------------------------------------------------------------------------
1 | package ovh
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "net/url"
10 | )
11 |
12 | func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
13 | recordID uint64, ipStr string, timestamp int64,
14 | ) (err error) {
15 | u := url.URL{
16 | Scheme: p.apiURL.Scheme,
17 | Host: p.apiURL.Host,
18 | Path: fmt.Sprintf("%s/domain/zone/%s/record/%d", p.apiURL.Path, p.domain, recordID),
19 | }
20 | putRecordsParams := struct {
21 | Target string `json:"target"`
22 | }{
23 | Target: ipStr,
24 | }
25 | bodyBytes, err := json.Marshal(putRecordsParams)
26 | if err != nil {
27 | return fmt.Errorf("json encoding request data: %w", err)
28 | }
29 |
30 | request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bytes.NewBuffer(bodyBytes))
31 | if err != nil {
32 | return fmt.Errorf("creating http request: %w", err)
33 | }
34 | request.Header.Add("Content-Type", "application/json;charset=utf-8")
35 | p.setHeaderCommon(request.Header)
36 | p.setHeaderAuth(request.Header, timestamp, request.Method, request.URL, bodyBytes)
37 |
38 | response, err := client.Do(request)
39 | if err != nil {
40 | return fmt.Errorf("doing http request: %w", err)
41 | }
42 |
43 | if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
44 | return extractAPIError(response)
45 | }
46 |
47 | _ = response.Body.Close()
48 |
49 | return nil
50 | }
51 |
--------------------------------------------------------------------------------
/internal/provider/providers/porkbun/error.go:
--------------------------------------------------------------------------------
1 | package porkbun
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 |
8 | "github.com/qdm12/ddns-updater/internal/provider/utils"
9 | )
10 |
11 | func makeErrorMessage(body io.Reader) (message string) {
12 | bytes, err := io.ReadAll(body)
13 | if err != nil {
14 | return "failed to read response body: " + err.Error()
15 | }
16 |
17 | var errorResponse struct {
18 | Status string `json:"status"`
19 | Message string `json:"message"`
20 | }
21 | err = json.Unmarshal(bytes, &errorResponse)
22 | if err != nil { // the encoding may change in the future
23 | return utils.ToSingleLine(string(bytes))
24 | }
25 |
26 | if errorResponse.Status != "ERROR" {
27 | return fmt.Sprintf("status %q is not expected ERROR: message is: %s",
28 | errorResponse.Status, errorResponse.Message)
29 | }
30 |
31 | return errorResponse.Message
32 | }
33 |
--------------------------------------------------------------------------------
/internal/provider/providers/vultr/updaterecord.go:
--------------------------------------------------------------------------------
1 | package vultr
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "net/netip"
11 | "net/url"
12 |
13 | "github.com/qdm12/ddns-updater/internal/provider/errors"
14 | )
15 |
16 | // https://www.vultr.com/api/#tag/dns/operation/update-dns-domain-record
17 | func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
18 | recordID string, ip netip.Addr,
19 | ) (err error) {
20 | u := url.URL{
21 | Scheme: "https",
22 | Host: "api.vultr.com",
23 | Path: fmt.Sprintf("/v2/domains/%s/records/%s", p.domain, recordID),
24 | }
25 |
26 | requestData := struct {
27 | Data string `json:"data"`
28 | Name string `json:"name"`
29 | TTL uint32 `json:"ttl,omitempty"`
30 | }{
31 | Data: ip.String(),
32 | Name: p.owner,
33 | TTL: p.ttl,
34 | }
35 |
36 | buffer := bytes.NewBuffer(nil)
37 | encoder := json.NewEncoder(buffer)
38 | err = encoder.Encode(requestData)
39 | if err != nil {
40 | return fmt.Errorf("json encoding request data: %w", err)
41 | }
42 |
43 | request, err := http.NewRequestWithContext(ctx, http.MethodPatch, u.String(), buffer)
44 | if err != nil {
45 | return fmt.Errorf("creating http request: %w", err)
46 | }
47 | p.setHeaders(request)
48 |
49 | response, err := client.Do(request)
50 | if err != nil {
51 | return err
52 | }
53 |
54 | bodyBytes, err := io.ReadAll(response.Body)
55 | if err != nil {
56 | _ = response.Body.Close()
57 | return fmt.Errorf("reading response body: %w", err)
58 | }
59 |
60 | err = response.Body.Close()
61 | if err != nil {
62 | return fmt.Errorf("closing response body: %w", err)
63 | }
64 |
65 | if response.StatusCode != http.StatusNoContent {
66 | return fmt.Errorf("%w: %d: %s",
67 | errors.ErrHTTPStatusNotValid, response.StatusCode,
68 | parseJSONErrorOrFullBody(bodyBytes))
69 | }
70 |
71 | errorMessage := parseJSONError(bodyBytes)
72 | if errorMessage != "" {
73 | return fmt.Errorf("%w: %s", errors.ErrUnsuccessful, errorMessage)
74 | }
75 |
76 | return nil
77 | }
78 |
--------------------------------------------------------------------------------
/internal/provider/utils/body.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "strings"
7 | )
8 |
9 | // ReadAndCleanBody reads the body, closes it, trims spaces from the body data
10 | // and converts it to lowercase.
11 | func ReadAndCleanBody(body io.ReadCloser) (cleanedBody string, err error) {
12 | b, err := io.ReadAll(body)
13 | if err != nil {
14 | return "", fmt.Errorf("reading body: %w", err)
15 | }
16 | err = body.Close()
17 | if err != nil {
18 | return "", fmt.Errorf("closing body: %w", err)
19 | }
20 |
21 | cleanedBody = string(b)
22 | cleanedBody = strings.TrimSpace(cleanedBody)
23 | cleanedBody = strings.ToLower(cleanedBody)
24 |
25 | return cleanedBody, nil
26 | }
27 |
--------------------------------------------------------------------------------
/internal/provider/utils/provider.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "github.com/qdm12/ddns-updater/internal/models"
5 | "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
6 | )
7 |
8 | func ToString(domain, owner string, provider models.Provider, ipVersion ipversion.IPVersion) string {
9 | return "[domain: " + domain + " | owner: " + owner + " | provider: " +
10 | string(provider) + " | ip: " + ipVersion.String() + "]"
11 | }
12 |
--------------------------------------------------------------------------------
/internal/provider/utils/singleline.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "io"
5 | "strings"
6 | )
7 |
8 | func BodyToSingleLine(body io.Reader) (s string) {
9 | b, err := io.ReadAll(body)
10 | if err != nil {
11 | return ""
12 | }
13 | data := string(b)
14 | return ToSingleLine(data)
15 | }
16 |
17 | func ToSingleLine(s string) (line string) {
18 | line = strings.ReplaceAll(s, "\n", "")
19 | line = strings.ReplaceAll(line, "\r", "")
20 | line = strings.ReplaceAll(line, " ", " ")
21 | line = strings.ReplaceAll(line, " ", " ")
22 | return line
23 | }
24 |
--------------------------------------------------------------------------------
/internal/records/html.go:
--------------------------------------------------------------------------------
1 | package records
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | "github.com/qdm12/ddns-updater/internal/constants"
9 | "github.com/qdm12/ddns-updater/internal/models"
10 | )
11 |
12 | func (r *Record) HTML(now time.Time) models.HTMLRow {
13 | const NotAvailable = "N/A"
14 | row := r.Provider.HTML()
15 | message := r.Message
16 | if r.Status == constants.UPTODATE {
17 | message = "no IP change for " + r.History.GetDurationSinceSuccess(now)
18 | }
19 | if message != "" {
20 | message = fmt.Sprintf("(%s)", message)
21 | }
22 | if r.Status == "" {
23 | row.Status = NotAvailable
24 | } else {
25 | row.Status = fmt.Sprintf("%s %s, %s",
26 | convertStatus(r.Status),
27 | message,
28 | time.Since(r.Time).Round(time.Second).String()+" ago")
29 | }
30 | currentIP := r.History.GetCurrentIP()
31 | if currentIP.IsValid() {
32 | row.CurrentIP = `` + currentIP.String() + ""
33 | } else {
34 | row.CurrentIP = NotAvailable
35 | }
36 | previousIPs := r.History.GetPreviousIPs()
37 | row.PreviousIPs = NotAvailable
38 | if len(previousIPs) > 0 {
39 | var previousIPsStr []string
40 | const maxPreviousIPs = 2
41 | for i, previousIP := range previousIPs {
42 | if i == maxPreviousIPs {
43 | previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i))
44 | break
45 | }
46 | previousIPsStr = append(previousIPsStr, previousIP.String())
47 | }
48 | row.PreviousIPs = strings.Join(previousIPsStr, ", ")
49 | }
50 | return row
51 | }
52 |
53 | func convertStatus(status models.Status) string {
54 | switch status {
55 | case constants.SUCCESS:
56 | return `Success`
57 | case constants.FAIL:
58 | return `Failure`
59 | case constants.UPTODATE:
60 | return `Up to date`
61 | case constants.UPDATING:
62 | return `Updating`
63 | case constants.UNSET:
64 | return `Unset`
65 | default:
66 | return "Unknown status"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/internal/records/records.go:
--------------------------------------------------------------------------------
1 | package records
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/qdm12/ddns-updater/internal/constants"
8 | "github.com/qdm12/ddns-updater/internal/models"
9 | "github.com/qdm12/ddns-updater/internal/provider"
10 | )
11 |
12 | // Record contains all the information to update and display a DNS record.
13 | type Record struct { // internal
14 | Provider provider.Provider // fixed
15 | History models.History // past information
16 | Status models.Status
17 | Message string
18 | Time time.Time
19 | LastBan *time.Time // nil means no last ban
20 | }
21 |
22 | // New returns a new Record with provider and some history.
23 | func New(provider provider.Provider, events []models.HistoryEvent) Record {
24 | return Record{
25 | Provider: provider,
26 | History: events,
27 | Status: constants.UNSET,
28 | }
29 | }
30 |
31 | func (r *Record) String() string {
32 | status := string(r.Status)
33 | if r.Message != "" {
34 | status += " (" + r.Message + ")"
35 | }
36 | return fmt.Sprintf("%s: %s %s; %s",
37 | r.Provider, status, r.Time.Format("2006-01-02 15:04:05 MST"), r.History)
38 | }
39 |
--------------------------------------------------------------------------------
/internal/resolver/resolver.go:
--------------------------------------------------------------------------------
1 | package resolver
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 | )
8 |
9 | func New(settings Settings) (resolver *net.Resolver, err error) {
10 | settings.setDefaults()
11 | err = settings.validate()
12 | if err != nil {
13 | return nil, fmt.Errorf("validating settings: %w", err)
14 | }
15 |
16 | if *settings.Address == "" {
17 | return net.DefaultResolver, nil
18 | }
19 |
20 | dialer := net.Dialer{Timeout: settings.Timeout}
21 | return &net.Resolver{
22 | PreferGo: true,
23 | Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
24 | const protocol = "udp"
25 | return dialer.DialContext(ctx, protocol, *settings.Address)
26 | },
27 | }, nil
28 | }
29 |
--------------------------------------------------------------------------------
/internal/resolver/settings.go:
--------------------------------------------------------------------------------
1 | package resolver
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net"
7 | "time"
8 |
9 | "github.com/qdm12/gosettings"
10 | )
11 |
12 | type Settings struct {
13 | Address *string
14 | Timeout time.Duration
15 | }
16 |
17 | func (s *Settings) setDefaults() {
18 | s.Address = gosettings.DefaultPointer(s.Address, "")
19 | const defaultTimeout = 5 * time.Second
20 | s.Timeout = gosettings.DefaultComparable(s.Timeout, defaultTimeout)
21 | }
22 |
23 | var (
24 | ErrAddressHostEmpty = errors.New("address host is empty")
25 | ErrAddressPortEmpty = errors.New("address port is empty")
26 | ErrTimeoutTooLow = errors.New("timeout is too low")
27 | )
28 |
29 | func (s Settings) validate() (err error) {
30 | if *s.Address != "" {
31 | host, port, err := net.SplitHostPort(*s.Address)
32 | if err != nil {
33 | return fmt.Errorf("splitting host and port from address: %w", err)
34 | }
35 |
36 | switch {
37 | case host == "":
38 | return fmt.Errorf("%w: in %s", ErrAddressHostEmpty, *s.Address)
39 | case port == "":
40 | return fmt.Errorf("%w: in %s", ErrAddressPortEmpty, *s.Address)
41 | }
42 | }
43 |
44 | const minTimeout = 10 * time.Millisecond
45 | if s.Timeout < minTimeout {
46 | return fmt.Errorf("%w: %s is below the minimum %s",
47 | ErrTimeoutTooLow, s.Timeout, minTimeout)
48 | }
49 |
50 | return nil
51 | }
52 |
--------------------------------------------------------------------------------
/internal/server/error.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | )
7 |
8 | type errJSONWrapper struct {
9 | Error string `json:"error"`
10 | }
11 |
12 | func httpError(w http.ResponseWriter, status int, errString string) {
13 | w.WriteHeader(status)
14 | if errString == "" {
15 | errString = http.StatusText(status)
16 | }
17 | body := errJSONWrapper{Error: errString}
18 | err := json.NewEncoder(w).Encode(body)
19 | if err != nil {
20 | panic(err)
21 | }
22 | }
23 |
24 | type errorsJSONWrapper struct {
25 | Errors []string `json:"errors"`
26 | }
27 |
28 | func httpErrors(w http.ResponseWriter, status int, errors []error) {
29 | w.WriteHeader(status)
30 |
31 | errs := make([]string, len(errors))
32 | for i := range errors {
33 | errs[i] = errors[i].Error()
34 | }
35 |
36 | body := errorsJSONWrapper{Errors: errs}
37 | err := json.NewEncoder(w).Encode(body)
38 | if err != nil {
39 | panic(err)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/internal/server/handler.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "embed"
6 | "io/fs"
7 | "net/http"
8 | "strings"
9 | "text/template"
10 | "time"
11 |
12 | "github.com/go-chi/chi/v5"
13 | "github.com/go-chi/chi/v5/middleware"
14 | )
15 |
16 | type handlers struct {
17 | ctx context.Context //nolint:containedctx
18 | // Objects
19 | db Database
20 | runner UpdateForcer
21 | indexTemplate *template.Template
22 | // Mockable functions
23 | timeNow func() time.Time
24 | }
25 |
26 | //go:embed ui/*
27 | var uiFS embed.FS
28 |
29 | func newHandler(ctx context.Context, rootURL string,
30 | db Database, runner UpdateForcer,
31 | ) http.Handler {
32 | indexTemplate := template.Must(template.ParseFS(uiFS, "ui/index.html"))
33 |
34 | staticFolder, err := fs.Sub(uiFS, "ui/static")
35 | if err != nil {
36 | panic(err)
37 | }
38 |
39 | handlers := &handlers{
40 | ctx: ctx,
41 | db: db,
42 | indexTemplate: indexTemplate,
43 | // TODO build information
44 | timeNow: time.Now,
45 | runner: runner,
46 | }
47 |
48 | router := chi.NewRouter()
49 |
50 | router.Use(middleware.RealIP)
51 | router.Use(middleware.Logger)
52 | rootURL = strings.TrimSuffix(rootURL, "/")
53 |
54 | if rootURL != "" {
55 | router.Handle(rootURL, http.RedirectHandler(rootURL+"/", http.StatusPermanentRedirect))
56 | }
57 | router.Get(rootURL+"/", handlers.index)
58 |
59 | router.Get(rootURL+"/update", handlers.update)
60 |
61 | router.Handle(rootURL+"/static/*", http.StripPrefix(rootURL+"/static/", http.FileServerFS(staticFolder)))
62 |
63 | return router
64 | }
65 |
--------------------------------------------------------------------------------
/internal/server/index.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/internal/models"
7 | )
8 |
9 | func (h *handlers) index(w http.ResponseWriter, _ *http.Request) {
10 | var htmlData models.HTMLData
11 | for _, record := range h.db.SelectAll() {
12 | row := record.HTML(h.timeNow())
13 | htmlData.Rows = append(htmlData.Rows, row)
14 | }
15 | err := h.indexTemplate.ExecuteTemplate(w, "index.html", htmlData)
16 | if err != nil {
17 | httpError(w, http.StatusInternalServerError, "failed generating webpage: "+err.Error())
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/internal/server/interfaces.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/qdm12/ddns-updater/internal/records"
7 | )
8 |
9 | type Database interface {
10 | SelectAll() (records []records.Record)
11 | }
12 |
13 | type UpdateForcer interface {
14 | ForceUpdate(ctx context.Context) (errors []error)
15 | }
16 |
17 | type Logger interface {
18 | Info(s string)
19 | Warn(s string)
20 | Error(s string)
21 | }
22 |
--------------------------------------------------------------------------------
/internal/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/qdm12/goservices/httpserver"
7 | )
8 |
9 | func New(ctx context.Context, address, rootURL string, db Database,
10 | logger Logger, runner UpdateForcer,
11 | ) (server *httpserver.Server, err error) {
12 | return httpserver.New(httpserver.Settings{
13 | Handler: newHandler(ctx, rootURL, db, runner),
14 | Address: &address,
15 | Logger: logger,
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/internal/server/ui/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/internal/server/ui/static/favicon.ico
--------------------------------------------------------------------------------
/internal/server/update.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | func (h *handlers) update(w http.ResponseWriter, _ *http.Request) {
8 | start := h.timeNow()
9 | errors := h.runner.ForceUpdate(h.ctx) //nolint:contextcheck
10 | duration := h.timeNow().Sub(start)
11 | if len(errors) > 0 {
12 | httpErrors(w, http.StatusInternalServerError, errors)
13 | return
14 | }
15 | w.WriteHeader(http.StatusAccepted)
16 | message := "All records updated successfully in " + duration.String()
17 | _, _ = w.Write([]byte(message))
18 | }
19 |
--------------------------------------------------------------------------------
/internal/shoutrrr/interfaces.go:
--------------------------------------------------------------------------------
1 | package shoutrrr
2 |
3 | type Erroer interface {
4 | Error(s string)
5 | }
6 |
--------------------------------------------------------------------------------
/internal/shoutrrr/settings.go:
--------------------------------------------------------------------------------
1 | package shoutrrr
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/containrrr/shoutrrr"
7 | "github.com/qdm12/gosettings"
8 | )
9 |
10 | type Settings struct {
11 | Addresses []string
12 | DefaultTitle string
13 | Logger Erroer
14 | }
15 |
16 | func (s *Settings) setDefaults() {
17 | s.Addresses = gosettings.DefaultSlice(s.Addresses, []string{})
18 | s.DefaultTitle = gosettings.DefaultComparable(s.DefaultTitle, "DDNS Updater")
19 | s.Logger = gosettings.DefaultComparable[Erroer](s.Logger, &noopLogger{})
20 | }
21 |
22 | func (s Settings) validate() (err error) {
23 | _, err = shoutrrr.CreateSender(s.Addresses...)
24 | if err != nil {
25 | return fmt.Errorf("shoutrrr addresses: %w", err)
26 | }
27 | return nil
28 | }
29 |
30 | type noopLogger struct{}
31 |
32 | func (l noopLogger) Error(_ string) {}
33 |
--------------------------------------------------------------------------------
/internal/shoutrrr/shoutrrr.go:
--------------------------------------------------------------------------------
1 | package shoutrrr
2 |
3 | import (
4 | "fmt"
5 | "net/url"
6 | "strings"
7 |
8 | "github.com/containrrr/shoutrrr"
9 | "github.com/containrrr/shoutrrr/pkg/router"
10 | )
11 |
12 | type Client struct {
13 | serviceRouter *router.ServiceRouter
14 | serviceNames []string
15 | defaultTitle string
16 | logger Erroer
17 | }
18 |
19 | func New(settings Settings) (client *Client, err error) {
20 | settings.setDefaults()
21 | err = settings.validate()
22 | if err != nil {
23 | return nil, fmt.Errorf("validating settings: %w", err)
24 | }
25 |
26 | for i, address := range settings.Addresses {
27 | settings.Addresses[i] = addDefaultTitle(address, settings.DefaultTitle)
28 | }
29 |
30 | serviceRouter, err := shoutrrr.CreateSender(settings.Addresses...)
31 | if err != nil {
32 | return nil, fmt.Errorf("creating service router: %w", err)
33 | }
34 |
35 | serviceNames := make([]string, len(settings.Addresses))
36 | for i, address := range settings.Addresses {
37 | serviceNames[i] = strings.Split(address, ":")[0]
38 | }
39 |
40 | return &Client{
41 | serviceRouter: serviceRouter,
42 | serviceNames: serviceNames,
43 | defaultTitle: settings.DefaultTitle,
44 | logger: settings.Logger,
45 | }, nil
46 | }
47 |
48 | func (c *Client) Notify(message string) {
49 | errs := c.serviceRouter.Send(message, nil)
50 | for i, err := range errs {
51 | if err != nil {
52 | c.logger.Error(c.serviceNames[i] + ": " + err.Error())
53 | }
54 | }
55 | }
56 |
57 | func addDefaultTitle(address, defaultTitle string) (updatedAddress string) {
58 | u, err := url.Parse(address)
59 | if err != nil {
60 | // address should already be validated
61 | panic(fmt.Sprintf("parsing address as url: %s", err))
62 | }
63 |
64 | urlValues := u.Query()
65 | if urlValues.Has("title") {
66 | return address
67 | }
68 |
69 | urlValues.Set("title", defaultTitle)
70 | u.RawQuery = urlValues.Encode()
71 | return u.String()
72 | }
73 |
--------------------------------------------------------------------------------
/internal/shoutrrr/shoutrrr_test.go:
--------------------------------------------------------------------------------
1 | package shoutrrr
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_addDefaultTitle(t *testing.T) {
10 | t.Parallel()
11 |
12 | testCases := map[string]struct {
13 | address string
14 | defaultTitle string
15 | updatedAddress string
16 | }{
17 | "generic_with_empty_title": {
18 | address: "generic://example.com?title=",
19 | defaultTitle: "DDNS Updater",
20 | updatedAddress: "generic://example.com?title=",
21 | },
22 | "generic_with_title": {
23 | address: "generic://example.com?title=MyTitle",
24 | defaultTitle: "DDNS Updater",
25 | updatedAddress: "generic://example.com?title=MyTitle",
26 | },
27 | "generic_without_title": {
28 | address: "generic://example.com",
29 | defaultTitle: "DDNS Updater",
30 | updatedAddress: "generic://example.com?title=DDNS+Updater",
31 | },
32 | }
33 |
34 | for name, testCase := range testCases {
35 | t.Run(name, func(t *testing.T) {
36 | t.Parallel()
37 |
38 | updatedAddress := addDefaultTitle(testCase.address, testCase.defaultTitle)
39 |
40 | assert.Equal(t, testCase.updatedAddress, updatedAddress)
41 | })
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/internal/system/umask_unix.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package system
4 |
5 | import (
6 | "io/fs"
7 | "syscall"
8 | )
9 |
10 | func SetUmask(umask fs.FileMode) {
11 | _ = syscall.Umask(int(umask))
12 | }
13 |
--------------------------------------------------------------------------------
/internal/system/umask_windows.go:
--------------------------------------------------------------------------------
1 | package system
2 |
3 | import (
4 | "io/fs"
5 | )
6 |
7 | func SetUmask(umask fs.FileMode) {}
8 |
--------------------------------------------------------------------------------
/internal/update/getip.go:
--------------------------------------------------------------------------------
1 | package update
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/netip"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
12 | )
13 |
14 | type getIPFunc func(ctx context.Context) (ip netip.Addr, err error)
15 |
16 | var ErrIPv6NotSupported = errors.New("IPv6 is not supported on this system")
17 |
18 | func tryAndRepeatGettingIP(ctx context.Context, getIPFunc getIPFunc,
19 | logger Logger, version ipversion.IPVersion,
20 | ) (ip netip.Addr, err error) {
21 | const tries = 3
22 | logMessagePrefix := "obtaining " + version.String() + " address"
23 | errs := make([]error, 0, tries)
24 | for try := range tries {
25 | ip, err = getIPFunc(ctx)
26 | if err != nil {
27 | errs = append(errs, err)
28 | logger.Debug(logMessagePrefix + ": try " + strconv.Itoa(try+1) + " of " +
29 | strconv.Itoa(tries) + " failed: " + err.Error())
30 | continue
31 | } else if try == 0 {
32 | return ip, nil
33 | }
34 |
35 | tryWord := "try"
36 | if try > 1 {
37 | tryWord = "tries"
38 | }
39 | logger.Info(logMessagePrefix + " succeeded after " +
40 | strconv.Itoa(try) + " failed " + tryWord)
41 | return ip, nil
42 | }
43 |
44 | allErrorsAreIPv6NotSupported := true
45 | for _, err := range errs {
46 | const ipv6NotSupportedMessage = "connect: cannot assign requested address"
47 | if !strings.Contains(err.Error(), ipv6NotSupportedMessage) {
48 | allErrorsAreIPv6NotSupported = false
49 | break
50 | }
51 | }
52 |
53 | err = &joinedErrors{errs: errs}
54 | if allErrorsAreIPv6NotSupported {
55 | return ip, fmt.Errorf("%w: after %d tries, errors were: %w", ErrIPv6NotSupported, tries, err)
56 | }
57 | return ip, fmt.Errorf("%s: after %d tries, errors were: %w", logMessagePrefix, tries, err)
58 | }
59 |
--------------------------------------------------------------------------------
/internal/update/interfaces.go:
--------------------------------------------------------------------------------
1 | package update
2 |
3 | import (
4 | "context"
5 | "net/netip"
6 |
7 | "github.com/qdm12/ddns-updater/internal/healthchecksio"
8 | "github.com/qdm12/ddns-updater/internal/records"
9 | )
10 |
11 | type PublicIPFetcher interface {
12 | IP(ctx context.Context) (netip.Addr, error)
13 | IP4(ctx context.Context) (netip.Addr, error)
14 | IP6(ctx context.Context) (netip.Addr, error)
15 | }
16 |
17 | type UpdaterInterface interface {
18 | Update(ctx context.Context, recordID uint, ip netip.Addr) (err error)
19 | }
20 |
21 | type Database interface {
22 | Select(recordID uint) (record records.Record, err error)
23 | SelectAll() (records []records.Record)
24 | Update(recordID uint, record records.Record) (err error)
25 | }
26 |
27 | type LookupIPer interface {
28 | LookupNetIP(ctx context.Context, network, host string) (ips []netip.Addr, err error)
29 | }
30 |
31 | type ShoutrrrClient interface {
32 | Notify(message string)
33 | }
34 |
35 | type Logger interface {
36 | DebugLogger
37 | Info(s string)
38 | Warn(s string)
39 | Error(s string)
40 | }
41 |
42 | type HealthchecksIOClient interface {
43 | Ping(ctx context.Context, state healthchecksio.State) (err error)
44 | }
45 |
--------------------------------------------------------------------------------
/internal/update/ipv6.go:
--------------------------------------------------------------------------------
1 | package update
2 |
3 | import (
4 | "fmt"
5 | "net/netip"
6 | )
7 |
8 | func ipv6WithSuffix(publicIP netip.Addr, ipv6Suffix netip.Prefix) (
9 | updateIP netip.Addr,
10 | ) {
11 | if !publicIP.IsValid() || !publicIP.Is6() || !ipv6Suffix.IsValid() {
12 | return publicIP
13 | }
14 |
15 | const ipv6Bits = 128
16 | const bitsInByte = 8
17 | prefixLength := (ipv6Bits - ipv6Suffix.Bits()) / bitsInByte
18 | ispPrefix := publicIP.AsSlice()[:prefixLength]
19 | localSuffix := ipv6Suffix.Addr().AsSlice()[prefixLength:]
20 | ipv6Bytes := ispPrefix // ispPrefix has already 16 bytes of capacity
21 | ipv6Bytes = append(ipv6Bytes, localSuffix...)
22 | updateIP, ok := netip.AddrFromSlice(ipv6Bytes)
23 | if !ok {
24 | panic(fmt.Sprintf("failed to create IPv6 address from merged bytes %v", ipv6Bytes))
25 | }
26 |
27 | return updateIP
28 | }
29 |
--------------------------------------------------------------------------------
/internal/update/ipv6_test.go:
--------------------------------------------------------------------------------
1 | package update
2 |
3 | import (
4 | "net/netip"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func Test_ipv6WithSuffix(t *testing.T) {
11 | t.Parallel()
12 |
13 | testCases := map[string]struct {
14 | publicIP netip.Addr
15 | ipv6Suffix netip.Prefix
16 | updateIP netip.Addr
17 | }{
18 | "blank_inputs": {},
19 | "ipv4_publicip": {
20 | publicIP: netip.MustParseAddr("1.2.3.4"),
21 | updateIP: netip.MustParseAddr("1.2.3.4"),
22 | },
23 | "invalid_suffix": {
24 | publicIP: netip.MustParseAddr("2001:db8::1"),
25 | updateIP: netip.MustParseAddr("2001:db8::1"),
26 | },
27 | "zero_suffix": {
28 | publicIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:2f54:6e9e:5e5f"),
29 | ipv6Suffix: netip.MustParsePrefix("0:0:0:0:0:0:0:0/0"),
30 | updateIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:2f54:6e9e:5e5f"),
31 | },
32 | "suffix_64": {
33 | publicIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:2f54:6e9e:5e5f"),
34 | ipv6Suffix: netip.MustParsePrefix("0:0:0:0:72ad:8fbb:a54e:bedd/64"),
35 | updateIP: netip.MustParseAddr("e4db:af36:82e:1221:" + "72ad:8fbb:a54e:bedd"),
36 | },
37 | "suffix_56": {
38 | publicIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:2f54:6e9e:5e5f"),
39 | ipv6Suffix: netip.MustParsePrefix("bbff:8199:4e2f:b4ba:72ad:8fbb:a54e:bedd/56"),
40 | updateIP: netip.MustParseAddr("e4db:af36:82e:1221:1b" + "ad:8fbb:a54e:bedd"),
41 | },
42 | "suffix_48": {
43 | publicIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:2f54:6e9e:5e5f"),
44 | ipv6Suffix: netip.MustParsePrefix("bbff:8199:4e2f:b4ba:72ad:8fbb:a54e:bedd/48"),
45 | updateIP: netip.MustParseAddr("e4db:af36:82e:1221:1b7f:" + "8fbb:a54e:bedd"),
46 | },
47 | }
48 |
49 | for name, testCase := range testCases {
50 | t.Run(name, func(t *testing.T) {
51 | t.Parallel()
52 | updateIP := ipv6WithSuffix(testCase.publicIP, testCase.ipv6Suffix)
53 | assert.Equal(t, testCase.updateIP.String(), updateIP.String())
54 | })
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/internal/update/mock_update/logclient.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/qdm12/ddns-updater/internal/update (interfaces: DebugLogger)
3 |
4 | // Package mock_update is a generated GoMock package.
5 | package mock_update
6 |
7 | import (
8 | reflect "reflect"
9 |
10 | gomock "github.com/golang/mock/gomock"
11 | )
12 |
13 | // MockDebugLogger is a mock of DebugLogger interface.
14 | type MockDebugLogger struct {
15 | ctrl *gomock.Controller
16 | recorder *MockDebugLoggerMockRecorder
17 | }
18 |
19 | // MockDebugLoggerMockRecorder is the mock recorder for MockDebugLogger.
20 | type MockDebugLoggerMockRecorder struct {
21 | mock *MockDebugLogger
22 | }
23 |
24 | // NewMockDebugLogger creates a new mock instance.
25 | func NewMockDebugLogger(ctrl *gomock.Controller) *MockDebugLogger {
26 | mock := &MockDebugLogger{ctrl: ctrl}
27 | mock.recorder = &MockDebugLoggerMockRecorder{mock}
28 | return mock
29 | }
30 |
31 | // EXPECT returns an object that allows the caller to indicate expected use.
32 | func (m *MockDebugLogger) EXPECT() *MockDebugLoggerMockRecorder {
33 | return m.recorder
34 | }
35 |
36 | // Debug mocks base method.
37 | func (m *MockDebugLogger) Debug(arg0 string) {
38 | m.ctrl.T.Helper()
39 | m.ctrl.Call(m, "Debug", arg0)
40 | }
41 |
42 | // Debug indicates an expected call of Debug.
43 | func (mr *MockDebugLoggerMockRecorder) Debug(arg0 interface{}) *gomock.Call {
44 | mr.mock.ctrl.T.Helper()
45 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockDebugLogger)(nil).Debug), arg0)
46 | }
47 |
--------------------------------------------------------------------------------
/k8s/README.md:
--------------------------------------------------------------------------------
1 | # Kubernetes
2 |
3 | This directory has example plain Kubernetes manifests for running DDNS-updater in Kubernetes.
4 |
5 | The Manifests have additional [Kustomize](https://kustomize.io/) overlays, which can be used to add an [ingress-route](https://kubernetes.io/docs/concepts/services-networking/ingress/) to ddns-updater.
6 |
7 | 1. Download the template files from the [`base` directory](base). For example with:
8 |
9 | ```sh
10 | curl -O https://raw.githubusercontent.com/qdm12/ddns-updater/master/k8s/base/deployment.yaml
11 | curl -O https://raw.githubusercontent.com/qdm12/ddns-updater/master/k8s/base/secret-config.yaml
12 | curl -O https://raw.githubusercontent.com/qdm12/ddns-updater/master/k8s/base/service.yaml
13 | curl -O https://raw.githubusercontent.com/qdm12/ddns-updater/master/k8s/base/kustomization.yaml
14 | ```
15 |
16 | 1. Modify `secret-config.yaml` as described in the [project readme](../README.md#configuration)
17 | 1. Use [kubectl](https://kubernetes.io/docs/reference/kubectl/) to apply the manifest:
18 |
19 | ```sh
20 | kubectl apply -k .
21 | ```
22 |
23 | 1. Connect the the web UI with a [kubectl port-forward](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/)
24 |
25 | ```sh
26 | kubectl port-forward svc/ddns-updater 8080:80
27 | ```
28 |
29 | The web UI should now be available at [http://localhost:8080](http://localhost:8080).
30 |
31 | ## Advanced usage
32 |
33 | Kustomize overlays can extend the installation:
34 |
35 | * [overlay/with-ingress](overlay/with-ingress/) - Basic **HTTP** Ingress ressource
36 | * [overlay/with-ingress-tls-cert-manager](overlay/with-ingress-tls-cert-manager/) - Basic **HTTPS** Ingress ressource which uses [cert-manager](https://github.com/cert-manager/cert-manager) to create certificates.
37 |
38 | To install with the overlay **just change dirctory in the overlay folder you want to install** and hit `kubectl apply -k .` .
39 |
--------------------------------------------------------------------------------
/k8s/base/deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: ddns-updater
5 | labels:
6 | app: ddns-updater
7 | spec:
8 | selector:
9 | matchLabels:
10 | app: ddns-updater
11 | template:
12 | metadata:
13 | labels:
14 | app: ddns-updater
15 | spec:
16 | containers:
17 | - name: ddns
18 | image: qmcgaw/ddns-updater:latest
19 | envFrom:
20 | - secretRef:
21 | name: ddns-updater-config
22 | ports:
23 | - containerPort: 8000
24 |
--------------------------------------------------------------------------------
/k8s/base/kustomization.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: kustomize.config.k8s.io/v1beta1
2 | kind: Kustomization
3 | resources:
4 | - deployment.yaml
5 | - secret-config.yaml
6 | - service.yaml
7 |
--------------------------------------------------------------------------------
/k8s/base/secret-config.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Secret
3 | metadata:
4 | name: ddns-updater-config
5 | type: Opaque
6 | stringData:
7 | CONFIG: '{"settings":[{"provider":"ddnss","provider_ip":true,"domain":"YOUR-DOMAIN","owner":"@","username":"YOUR-USERNAME","password":"YOUR-PASSWORD","ip_version":"ipv4"}]}'
8 |
--------------------------------------------------------------------------------
/k8s/base/service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: ddns-updater
5 | spec:
6 | selector:
7 | app: ddns-updater
8 | type: ClusterIP
9 | ports:
10 | - name: http
11 | port: 80
12 | targetPort: 8000
13 |
--------------------------------------------------------------------------------
/k8s/overlay/with-ingress-tls-cert-manager/ingress.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 | metadata:
4 | annotations:
5 | cert-manager.io/cluster-issuer: default
6 | ingress.kubernetes.io/force-ssl-redirect: "true"
7 | name: ddns-updater
8 | spec:
9 | rules:
10 | - host: localhost
11 | http:
12 | paths:
13 | - path: /
14 | pathType: ImplementationSpecific
15 | backend:
16 | service:
17 | name: ddns-updater
18 | port:
19 | number: 80
20 | tls:
21 | - hosts:
22 | - localhost
23 | secretName: ddns-updater-tls
24 |
--------------------------------------------------------------------------------
/k8s/overlay/with-ingress-tls-cert-manager/kustomization.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: kustomize.config.k8s.io/v1beta1
2 | kind: Kustomization
3 | resources:
4 | - ../../base
5 | - ingress.yaml
6 |
7 | # this patches the hostname of the Ingress
8 | patches:
9 | - patch: |-
10 | - op: replace
11 | path: /spec/rules/0/host
12 | value: localhost # add your fqdn
13 | - op: replace
14 | path: /spec/tls/0/hosts/0
15 | value: localhost # add your fqdn
16 | target:
17 | kind: Ingress
18 | name: ddns-updater
19 |
--------------------------------------------------------------------------------
/k8s/overlay/with-ingress/ingress.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 | metadata:
4 | name: ddns-updater
5 | spec:
6 | rules:
7 | - host: localhost
8 | http:
9 | paths:
10 | - path: /
11 | pathType: ImplementationSpecific
12 | backend:
13 | service:
14 | name: ddns-updater
15 | port:
16 | name: http
17 |
--------------------------------------------------------------------------------
/k8s/overlay/with-ingress/kustomization.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: kustomize.config.k8s.io/v1beta1
2 | kind: Kustomization
3 | resources:
4 | - ../../base
5 | - ingress.yaml
6 |
7 | # this patches the hostname of the Ingress
8 | patches:
9 | - patch: |-
10 | - op: replace
11 | path: /spec/rules/0/host
12 | value: localhost # add your fqdn
13 | target:
14 | kind: Ingress
15 | name: ddns-updater
16 |
--------------------------------------------------------------------------------
/pkg/ipextract/ipextract.go:
--------------------------------------------------------------------------------
1 | package ipextract
2 |
3 | import (
4 | "net/netip"
5 | "strings"
6 | )
7 |
8 | // IPv4 extracts all valid IPv4 addresses from a given
9 | // text string. Each IPv4 address must be separated by a character
10 | // not part of the IPv4 alphabet (0123456789.).
11 | // Performance-wise, this extraction is at least x3 times faster
12 | // than using a regular expression.
13 | func IPv4(text string) (addresses []netip.Addr) {
14 | const ipv4Alphabet = "0123456789."
15 | return extract(text, ipv4Alphabet)
16 | }
17 |
18 | // IPv6 extracts all valid IPv6 addresses from a given
19 | // text string. Each IPv6 address must be separated by a character
20 | // not part of the IPv6 alphabet (0123456789abcdefABCDEF:).
21 | // Performance-wise, this extraction is at least x3 times faster
22 | // than using a regular expression.
23 | func IPv6(text string) (addresses []netip.Addr) {
24 | const ipv6Alphabet = "0123456789abcdefABCDEF:"
25 | return extract(text, ipv6Alphabet)
26 | }
27 |
28 | func extract(text string, alphabet string) (addresses []netip.Addr) {
29 | addressesSeen := make(map[netip.Addr]struct{})
30 | var start, end int
31 | for {
32 | for i := start; i < len(text); i++ {
33 | r := rune(text[i])
34 | if !strings.ContainsRune(alphabet, r) {
35 | break
36 | }
37 | end++
38 | }
39 |
40 | possibleIPString := text[start:end]
41 | ipAddress, err := netip.ParseAddr(possibleIPString)
42 | if err == nil { // Valid IP address found
43 | _, seen := addressesSeen[ipAddress]
44 | if !seen {
45 | addressesSeen[ipAddress] = struct{}{}
46 | addresses = append(addresses, ipAddress)
47 | }
48 | }
49 |
50 | if end == len(text) {
51 | return addresses
52 | }
53 |
54 | start = end + 1 // + 1 to skip non alphabet match character
55 | end = start
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/client.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/miekg/dns"
8 | )
9 |
10 | //go:generate mockgen -destination=mock_$GOPACKAGE/$GOFILE . Client
11 |
12 | // Client is an interface for the DNS client used in the implementation in this package.
13 | // You SHOULD NOT use this interface anywhere as it is implementation specific.
14 | type Client interface {
15 | ExchangeContext(ctx context.Context, m *dns.Msg, a string) (r *dns.Msg, rtt time.Duration, err error)
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/dns.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import "time"
4 |
5 | type Fetcher struct {
6 | ring ring
7 | timeout time.Duration
8 | }
9 |
10 | type ring struct {
11 | // counter is used to get an index in the providers slice
12 | counter *uint32 // uint32 for 32 bit systems atomic operations
13 | providers []Provider
14 | }
15 |
16 | func New(options ...Option) (f *Fetcher, err error) {
17 | settings := newDefaultSettings()
18 | for _, option := range options {
19 | err = option(&settings)
20 | if err != nil {
21 | return nil, err
22 | }
23 | }
24 |
25 | return &Fetcher{
26 | ring: ring{
27 | counter: new(uint32),
28 | providers: settings.providers,
29 | },
30 | timeout: settings.timeout,
31 | }, nil
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/dns_test.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func Test_New(t *testing.T) {
12 | t.Parallel()
13 |
14 | impl, err := New(SetTimeout(time.Hour))
15 | require.NoError(t, err)
16 |
17 | assert.NotNil(t, impl.ring.counter)
18 | assert.NotEmpty(t, impl.ring.providers)
19 | }
20 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/integration_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 | // +build integration
3 |
4 | package dns
5 |
6 | import (
7 | "context"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func Test_integration(t *testing.T) {
15 | t.Parallel()
16 |
17 | fetcher, err := New(SetProviders(Cloudflare, OpenDNS))
18 | require.NoError(t, err)
19 |
20 | ctx := context.Background()
21 |
22 | publicIP1, err := fetcher.IP4(ctx)
23 | require.NoError(t, err)
24 | assert.NotNil(t, publicIP1)
25 |
26 | publicIP2, err := fetcher.IP4(ctx)
27 | require.NoError(t, err)
28 | assert.NotNil(t, publicIP2)
29 |
30 | assert.Equal(t, publicIP1.String(), publicIP2.String())
31 |
32 | t.Logf("Public IP is %s", publicIP1)
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/ip.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "errors"
7 | "fmt"
8 | "net/netip"
9 | "sync/atomic"
10 |
11 | "github.com/miekg/dns"
12 | )
13 |
14 | var ErrIPNotFoundForVersion = errors.New("IP addresses found but not for IP version")
15 |
16 | func (f *Fetcher) IP(ctx context.Context) (publicIP netip.Addr, err error) {
17 | publicIPs, err := f.ip(ctx, "tcp")
18 | if err != nil {
19 | return netip.Addr{}, err
20 | }
21 | return publicIPs[0], nil
22 | }
23 |
24 | func (f *Fetcher) IP4(ctx context.Context) (publicIP netip.Addr, err error) {
25 | publicIPs, err := f.ip(ctx, "tcp4")
26 | if err != nil {
27 | return netip.Addr{}, err
28 | }
29 |
30 | for _, ip := range publicIPs {
31 | if ip.Is4() {
32 | return ip, nil
33 | }
34 | }
35 | return netip.Addr{}, fmt.Errorf("%w: ipv4", ErrIPNotFoundForVersion)
36 | }
37 |
38 | func (f *Fetcher) IP6(ctx context.Context) (publicIP netip.Addr, err error) {
39 | publicIPs, err := f.ip(ctx, "tcp6")
40 | if err != nil {
41 | return netip.Addr{}, err
42 | }
43 |
44 | for _, ip := range publicIPs {
45 | if ip.Is6() {
46 | return ip, nil
47 | }
48 | }
49 | return netip.Addr{}, fmt.Errorf("%w: ipv6", ErrIPNotFoundForVersion)
50 | }
51 |
52 | func (f *Fetcher) ip(ctx context.Context, network string) (
53 | publicIPs []netip.Addr, err error,
54 | ) {
55 | index := int(atomic.AddUint32(f.ring.counter, 1)) % len(f.ring.providers)
56 | providerData := f.ring.providers[index].data()
57 |
58 | client := &dns.Client{
59 | Net: network + "-tls",
60 | Timeout: f.timeout,
61 | DialTimeout: f.timeout,
62 | TLSConfig: &tls.Config{
63 | MinVersion: tls.VersionTLS12,
64 | ServerName: providerData.TLSName,
65 | },
66 | }
67 |
68 | return fetch(ctx, client, network, providerData)
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/mock_dns/client.go:
--------------------------------------------------------------------------------
1 | // Code generated by MockGen. DO NOT EDIT.
2 | // Source: github.com/qdm12/ddns-updater/pkg/publicip/dns (interfaces: Client)
3 |
4 | // Package mock_dns is a generated GoMock package.
5 | package mock_dns
6 |
7 | import (
8 | context "context"
9 | reflect "reflect"
10 | time "time"
11 |
12 | gomock "github.com/golang/mock/gomock"
13 | dns "github.com/miekg/dns"
14 | )
15 |
16 | // MockClient is a mock of Client interface.
17 | type MockClient struct {
18 | ctrl *gomock.Controller
19 | recorder *MockClientMockRecorder
20 | }
21 |
22 | // MockClientMockRecorder is the mock recorder for MockClient.
23 | type MockClientMockRecorder struct {
24 | mock *MockClient
25 | }
26 |
27 | // NewMockClient creates a new mock instance.
28 | func NewMockClient(ctrl *gomock.Controller) *MockClient {
29 | mock := &MockClient{ctrl: ctrl}
30 | mock.recorder = &MockClientMockRecorder{mock}
31 | return mock
32 | }
33 |
34 | // EXPECT returns an object that allows the caller to indicate expected use.
35 | func (m *MockClient) EXPECT() *MockClientMockRecorder {
36 | return m.recorder
37 | }
38 |
39 | // ExchangeContext mocks base method.
40 | func (m *MockClient) ExchangeContext(arg0 context.Context, arg1 *dns.Msg, arg2 string) (*dns.Msg, time.Duration, error) {
41 | m.ctrl.T.Helper()
42 | ret := m.ctrl.Call(m, "ExchangeContext", arg0, arg1, arg2)
43 | ret0, _ := ret[0].(*dns.Msg)
44 | ret1, _ := ret[1].(time.Duration)
45 | ret2, _ := ret[2].(error)
46 | return ret0, ret1, ret2
47 | }
48 |
49 | // ExchangeContext indicates an expected call of ExchangeContext.
50 | func (mr *MockClientMockRecorder) ExchangeContext(arg0, arg1, arg2 interface{}) *gomock.Call {
51 | mr.mock.ctrl.T.Helper()
52 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExchangeContext", reflect.TypeOf((*MockClient)(nil).ExchangeContext), arg0, arg1, arg2)
53 | }
54 |
--------------------------------------------------------------------------------
/pkg/publicip/dns/options.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import "time"
4 |
5 | type settings struct {
6 | providers []Provider
7 | timeout time.Duration
8 | }
9 |
10 | func newDefaultSettings() settings {
11 | const defaultTimeout = 3 * time.Second
12 | return settings{
13 | providers: ListProviders(),
14 | timeout: defaultTimeout,
15 | }
16 | }
17 |
18 | type Option func(s *settings) error
19 |
20 | func SetProviders(first Provider, providers ...Provider) Option {
21 | return func(s *settings) (err error) {
22 | providers = append(providers, first)
23 | for _, provider := range providers {
24 | err = ValidateProvider(provider)
25 | if err != nil {
26 | return err
27 | }
28 | }
29 | s.providers = providers
30 | return nil
31 | }
32 | }
33 |
34 | func SetTimeout(timeout time.Duration) Option {
35 | return func(s *settings) (err error) {
36 | s.timeout = timeout
37 | return nil
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/publicip/http/http.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "net/http"
5 | "sort"
6 | "strings"
7 | "sync"
8 | "time"
9 |
10 | "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
11 | )
12 |
13 | type Fetcher struct {
14 | client *http.Client
15 | timeout time.Duration
16 | ip4or6 *urlsRing // URLs to get ipv4 or ipv6
17 | ip4 *urlsRing // URLs to get ipv4 only
18 | ip6 *urlsRing // URLs to get ipv6 only
19 | }
20 |
21 | type urlsRing struct {
22 | index int
23 | urls []string
24 | banned map[int]string // urls indices <-> ban error string
25 | mutex sync.Mutex
26 | }
27 |
28 | func New(client *http.Client, options ...Option) (f *Fetcher, err error) {
29 | settings := newDefaultSettings()
30 | for _, option := range options {
31 | err = option(&settings)
32 | if err != nil {
33 | return nil, err
34 | }
35 | }
36 |
37 | return &Fetcher{
38 | client: client,
39 | timeout: settings.timeout,
40 | ip4or6: newRing(settings.providersIP, ipversion.IP4or6),
41 | ip4: newRing(settings.providersIP4, ipversion.IP4),
42 | ip6: newRing(settings.providersIP6, ipversion.IP6),
43 | }, nil
44 | }
45 |
46 | func newRing(providers []Provider, ipVersion ipversion.IPVersion) (ring *urlsRing) {
47 | ring = new(urlsRing)
48 | ring.banned = make(map[int]string)
49 | ring.urls = make([]string, len(providers))
50 | for i, provider := range providers {
51 | ring.urls[i], _ = provider.url(ipVersion)
52 | }
53 | return ring
54 | }
55 |
56 | func (u *urlsRing) banString() string {
57 | parts := make([]string, 0, len(u.banned))
58 | for i, errString := range u.banned {
59 | part := errString + " (" + u.urls[i] + ")"
60 | parts = append(parts, part)
61 | }
62 | sort.Strings(parts) // for predicability
63 | return strings.Join(parts, ", ")
64 | }
65 |
--------------------------------------------------------------------------------
/pkg/publicip/http/integration_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 | // +build integration
3 |
4 | package http
5 |
6 | import (
7 | "context"
8 | "net/http"
9 | "testing"
10 |
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func Test_integration(t *testing.T) {
16 | t.Parallel()
17 |
18 | client := &http.Client{}
19 |
20 | fetcher, err := New(client, SetProvidersIP(Ipify))
21 | require.NoError(t, err)
22 |
23 | ctx := context.Background()
24 |
25 | publicIP1, err := fetcher.IP4(ctx)
26 | require.NoError(t, err)
27 | assert.NotNil(t, publicIP1)
28 |
29 | publicIP2, err := fetcher.IP4(ctx)
30 | require.NoError(t, err)
31 | assert.NotNil(t, publicIP2)
32 |
33 | assert.Equal(t, publicIP1, publicIP2)
34 |
35 | t.Logf("Public IP is %s", publicIP1)
36 | }
37 |
--------------------------------------------------------------------------------
/pkg/publicip/http/ip.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/netip"
8 | "strings"
9 |
10 | "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
11 | )
12 |
13 | func (f *Fetcher) IP(ctx context.Context) (publicIP netip.Addr, err error) {
14 | return f.ip(ctx, f.ip4or6, ipversion.IP4or6)
15 | }
16 |
17 | func (f *Fetcher) IP4(ctx context.Context) (publicIP netip.Addr, err error) {
18 | return f.ip(ctx, f.ip4, ipversion.IP4)
19 | }
20 |
21 | func (f *Fetcher) IP6(ctx context.Context) (publicIP netip.Addr, err error) {
22 | return f.ip(ctx, f.ip6, ipversion.IP6)
23 | }
24 |
25 | func (f *Fetcher) ip(ctx context.Context, ring *urlsRing, version ipversion.IPVersion) (
26 | publicIP netip.Addr, err error,
27 | ) {
28 | ring.mutex.Lock()
29 |
30 | var index int
31 | banned := 0
32 | for {
33 | ring.index = (ring.index + 1) % len(ring.urls)
34 | index = ring.index
35 | _, indexIsBanned := ring.banned[index]
36 | if !indexIsBanned {
37 | break
38 | }
39 | banned++
40 | if banned == len(ring.urls) {
41 | banString := ring.banString()
42 | ring.mutex.Unlock()
43 | return netip.Addr{}, fmt.Errorf("%w: %s", ErrBanned, banString)
44 | }
45 | }
46 |
47 | ring.mutex.Unlock()
48 |
49 | url := ring.urls[index]
50 |
51 | ctx, cancel := context.WithTimeout(ctx, f.timeout)
52 | defer cancel()
53 |
54 | publicIP, err = fetch(ctx, f.client, url, version)
55 | if err != nil {
56 | if errors.Is(err, ErrBanned) {
57 | ring.mutex.Lock()
58 | ring.banned[index] = strings.ReplaceAll(err.Error(), ErrBanned.Error()+": ", "")
59 | ring.mutex.Unlock()
60 | }
61 | return netip.Addr{}, err
62 | }
63 | return publicIP, nil
64 | }
65 |
--------------------------------------------------------------------------------
/pkg/publicip/http/options.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
7 | )
8 |
9 | type settings struct {
10 | providersIP []Provider
11 | providersIP4 []Provider
12 | providersIP6 []Provider
13 | timeout time.Duration
14 | }
15 |
16 | func newDefaultSettings() settings {
17 | const defaultTimeout = 5 * time.Second
18 | return settings{
19 | providersIP: []Provider{Ipify},
20 | providersIP4: []Provider{Ipify},
21 | providersIP6: []Provider{Ipify},
22 | timeout: defaultTimeout,
23 | }
24 | }
25 |
26 | type Option func(s *settings) error
27 |
28 | func SetProvidersIP(first Provider, providers ...Provider) Option {
29 | providers = append(providers, first)
30 | return func(s *settings) (err error) {
31 | for _, provider := range providers {
32 | err = ValidateProvider(provider, ipversion.IP4or6)
33 | if err != nil {
34 | return err
35 | }
36 | }
37 | s.providersIP = providers
38 | return nil
39 | }
40 | }
41 |
42 | func SetProvidersIP4(first Provider, providers ...Provider) Option {
43 | providers = append(providers, first)
44 | return func(s *settings) (err error) {
45 | for _, provider := range providers {
46 | err = ValidateProvider(provider, ipversion.IP4)
47 | if err != nil {
48 | return err
49 | }
50 | }
51 | s.providersIP4 = providers
52 | return nil
53 | }
54 | }
55 |
56 | func SetProvidersIP6(first Provider, providers ...Provider) Option {
57 | providers = append(providers, first)
58 | return func(s *settings) (err error) {
59 | for _, provider := range providers {
60 | err = ValidateProvider(provider, ipversion.IP6)
61 | if err != nil {
62 | return err
63 | }
64 | }
65 | s.providersIP6 = providers
66 | return nil
67 | }
68 | }
69 |
70 | func SetTimeout(timeout time.Duration) Option {
71 | return func(s *settings) (err error) {
72 | s.timeout = timeout
73 | return nil
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/pkg/publicip/http/roudtrip_test.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import "net/http"
4 |
5 | type roundTripFunc func(r *http.Request) (*http.Response, error)
6 |
7 | func (s roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
8 | return s(r)
9 | }
10 |
--------------------------------------------------------------------------------
/pkg/publicip/info/errors.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrTooManyRequests = errors.New("too many requests sent")
7 | ErrBadHTTPStatus = errors.New("bad HTTP status received")
8 | )
9 |
--------------------------------------------------------------------------------
/pkg/publicip/info/http.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import (
4 | "io"
5 | "strings"
6 | )
7 |
8 | func bodyToSingleLine(body io.Reader) (s string) {
9 | b, err := io.ReadAll(body)
10 | if err != nil {
11 | return ""
12 | }
13 | data := string(b)
14 | return toSingleLine(data)
15 | }
16 |
17 | func toSingleLine(s string) (line string) {
18 | line = strings.ReplaceAll(s, "\n", "")
19 | line = strings.ReplaceAll(line, "\r", "")
20 | line = strings.ReplaceAll(line, " ", " ")
21 | line = strings.ReplaceAll(line, " ", " ")
22 | return line
23 | }
24 |
--------------------------------------------------------------------------------
/pkg/publicip/info/ipinfo.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/netip"
9 | )
10 |
11 | func newIpinfo(client *http.Client) *ipinfo {
12 | return &ipinfo{
13 | client: client,
14 | }
15 | }
16 |
17 | type ipinfo struct {
18 | client *http.Client
19 | }
20 |
21 | func (p *ipinfo) get(ctx context.Context, ip netip.Addr) (
22 | result Result, err error,
23 | ) {
24 | result.Source = string(Ipinfo)
25 |
26 | url := "https://ipinfo.io/"
27 | if ip.IsValid() {
28 | url += ip.String()
29 | }
30 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
31 | if err != nil {
32 | return result, fmt.Errorf("creating request: %w", err)
33 | }
34 |
35 | response, err := p.client.Do(request)
36 | if err != nil {
37 | return result, fmt.Errorf("doing request: %w", err)
38 | }
39 |
40 | switch response.StatusCode {
41 | case http.StatusOK:
42 | case http.StatusForbidden, http.StatusTooManyRequests:
43 | bodyString := bodyToSingleLine(response.Body)
44 | _ = response.Body.Close()
45 | return result, fmt.Errorf("%w (%s)", ErrTooManyRequests, bodyString)
46 | default:
47 | bodyString := bodyToSingleLine(response.Body)
48 | _ = response.Body.Close()
49 | return result, fmt.Errorf("%w: %d %s (%s)", ErrBadHTTPStatus,
50 | response.StatusCode, response.Status, bodyString)
51 | }
52 |
53 | decoder := json.NewDecoder(response.Body)
54 | var data struct {
55 | IP netip.Addr `json:"ip"`
56 | Region string `json:"region"`
57 | Country string `json:"country"`
58 | City string `json:"city"`
59 | }
60 | err = decoder.Decode(&data)
61 | if err != nil {
62 | return result, fmt.Errorf("decoding JSON response: %w", err)
63 | }
64 |
65 | result.IP = data.IP
66 | if data.Region != "" {
67 | result.Region = stringPtr(data.Region)
68 | }
69 | if data.City != "" {
70 | result.City = stringPtr(data.City)
71 | }
72 | if data.Country != "" {
73 | country := countryCodeToName(data.Country)
74 | result.Country = stringPtr(country)
75 | }
76 |
77 | return result, nil
78 | }
79 |
--------------------------------------------------------------------------------
/pkg/publicip/info/options.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | type Option func(s *settings) error
4 |
5 | func SetProviders(first Provider, providers ...Provider) Option {
6 | return func(s *settings) (err error) {
7 | providers = append(providers, first)
8 | for _, provider := range providers {
9 | err = ValidateProvider(provider)
10 | if err != nil {
11 | return err
12 | }
13 | }
14 | s.providers = providers
15 | return nil
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/pkg/publicip/info/provider.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/netip"
9 | )
10 |
11 | type Provider string
12 |
13 | const (
14 | Ipinfo Provider = "ipinfo"
15 | IP2Location Provider = "ip2location"
16 | )
17 |
18 | func ListProviders() []Provider {
19 | return []Provider{
20 | Ipinfo,
21 | IP2Location,
22 | }
23 | }
24 |
25 | var ErrUnknownProvider = errors.New("unknown public IP information provider")
26 |
27 | func ValidateProvider(provider Provider) error {
28 | for _, possible := range ListProviders() {
29 | if provider == possible {
30 | return nil
31 | }
32 | }
33 | return fmt.Errorf("%w: %s", ErrUnknownProvider, provider)
34 | }
35 |
36 | type provider interface {
37 | get(ctx context.Context, ip netip.Addr) (result Result, err error)
38 | }
39 |
40 | func newProvider(providerName Provider, client *http.Client) provider { //nolint:ireturn
41 | switch providerName {
42 | case Ipinfo:
43 | return newIpinfo(client)
44 | case IP2Location:
45 | return newIP2Location(client)
46 | default:
47 | panic(fmt.Sprintf("provider %s not implemented", providerName))
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/pkg/publicip/info/rand.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import (
4 | "hash/maphash"
5 | "math/rand"
6 | )
7 |
8 | var _ rand.Source = new(mapHashSource)
9 |
10 | type mapHashSource struct{}
11 |
12 | func (s *mapHashSource) Int63() int64 {
13 | v := new(maphash.Hash).Sum64()
14 | return int64(v >> 1) //nolint:gosec
15 | }
16 |
17 | func (s *mapHashSource) Seed(_ int64) {}
18 |
--------------------------------------------------------------------------------
/pkg/publicip/info/result.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | import "net/netip"
4 |
5 | type Result struct {
6 | IP netip.Addr
7 | Country *string
8 | Region *string
9 | City *string
10 | Source string
11 | }
12 |
13 | func stringPtr(s string) *string { return &s }
14 |
--------------------------------------------------------------------------------
/pkg/publicip/info/settings.go:
--------------------------------------------------------------------------------
1 | package info
2 |
3 | type settings struct {
4 | providers []Provider
5 | }
6 |
7 | func (s *settings) setDefaults() {
8 | if len(s.providers) == 0 {
9 | s.providers = ListProviders()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/pkg/publicip/ipversion/ipversion.go:
--------------------------------------------------------------------------------
1 | package ipversion
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 | )
8 |
9 | type IPVersion uint8
10 |
11 | const (
12 | IP4or6 IPVersion = iota
13 | IP4
14 | IP6
15 | )
16 |
17 | func (v IPVersion) String() string {
18 | switch v {
19 | case IP4or6:
20 | return "ipv4 or ipv6"
21 | case IP4:
22 | return "ipv4"
23 | case IP6:
24 | return "ipv6"
25 | default:
26 | return "ip?"
27 | }
28 | }
29 |
30 | var ErrInvalidIPVersion = errors.New("invalid IP version")
31 |
32 | func Parse(s string) (version IPVersion, err error) {
33 | switch strings.ToLower(s) {
34 | case "ipv4 or ipv6":
35 | return IP4or6, nil
36 | case "ipv4":
37 | return IP4, nil
38 | case "ipv6":
39 | return IP6, nil
40 | default:
41 | return IP4or6, fmt.Errorf("%w: %q", ErrInvalidIPVersion, s)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/publicip/publicip.go:
--------------------------------------------------------------------------------
1 | package publicip
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/netip"
7 |
8 | "github.com/qdm12/ddns-updater/pkg/publicip/dns"
9 | "github.com/qdm12/ddns-updater/pkg/publicip/http"
10 | )
11 |
12 | type ipFetcher interface {
13 | IP(ctx context.Context) (ip netip.Addr, err error)
14 | IP4(ctx context.Context) (ipv4 netip.Addr, err error)
15 | IP6(ctx context.Context) (ipv6 netip.Addr, err error)
16 | }
17 |
18 | type Fetcher struct {
19 | settings settings
20 | fetchers []ipFetcher
21 | // Cycling effect if both are enabled
22 | counter *uint32 // 32 bit for 32 bit systems
23 | }
24 |
25 | var ErrNoFetchTypeSpecified = errors.New("at least one fetcher type must be specified")
26 |
27 | func NewFetcher(dnsSettings DNSSettings, httpSettings HTTPSettings) (f *Fetcher, err error) {
28 | settings := settings{
29 | dns: dnsSettings,
30 | http: httpSettings,
31 | }
32 |
33 | fetcher := &Fetcher{
34 | settings: settings,
35 | counter: new(uint32),
36 | }
37 |
38 | if settings.dns.Enabled {
39 | subFetcher, err := dns.New(settings.dns.Options...)
40 | if err != nil {
41 | return nil, err
42 | }
43 | fetcher.fetchers = append(fetcher.fetchers, subFetcher)
44 | }
45 |
46 | if settings.http.Enabled {
47 | subFetcher, err := http.New(settings.http.Client, settings.http.Options...)
48 | if err != nil {
49 | return nil, err
50 | }
51 | fetcher.fetchers = append(fetcher.fetchers, subFetcher)
52 | }
53 |
54 | if len(fetcher.fetchers) == 0 {
55 | return nil, ErrNoFetchTypeSpecified
56 | }
57 |
58 | return fetcher, nil
59 | }
60 |
61 | func (f *Fetcher) IP(ctx context.Context) (ip netip.Addr, err error) {
62 | return f.getSubFetcher().IP(ctx)
63 | }
64 |
65 | func (f *Fetcher) IP4(ctx context.Context) (ipv4 netip.Addr, err error) {
66 | return f.getSubFetcher().IP4(ctx)
67 | }
68 |
69 | func (f *Fetcher) IP6(ctx context.Context) (ipv6 netip.Addr, err error) {
70 | return f.getSubFetcher().IP6(ctx)
71 | }
72 |
--------------------------------------------------------------------------------
/pkg/publicip/settings.go:
--------------------------------------------------------------------------------
1 | package publicip
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/qdm12/ddns-updater/pkg/publicip/dns"
7 | iphttp "github.com/qdm12/ddns-updater/pkg/publicip/http"
8 | )
9 |
10 | type settings struct {
11 | // If both dns and http are enabled it will cycle between both of them.
12 | dns DNSSettings
13 | http HTTPSettings
14 | }
15 |
16 | type DNSSettings struct {
17 | Enabled bool
18 | Options []dns.Option
19 | }
20 |
21 | type HTTPSettings struct {
22 | Enabled bool
23 | Client *http.Client
24 | Options []iphttp.Option
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/publicip/subfetcher.go:
--------------------------------------------------------------------------------
1 | package publicip
2 |
3 | import (
4 | "sync/atomic"
5 | )
6 |
7 | //nolint:ireturn
8 | func (f *Fetcher) getSubFetcher() ipFetcher {
9 | fetcher := f.fetchers[0]
10 | if len(f.fetchers) > 1 { // cycling effect
11 | index := int(atomic.AddUint32(f.counter, 1)) % len(f.fetchers)
12 | fetcher = f.fetchers[index]
13 | }
14 | return fetcher
15 | }
16 |
--------------------------------------------------------------------------------
/readme/duckdns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/duckdns.png
--------------------------------------------------------------------------------
/readme/godaddy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/godaddy.png
--------------------------------------------------------------------------------
/readme/godaddy1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/godaddy1.gif
--------------------------------------------------------------------------------
/readme/godaddy2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/godaddy2.gif
--------------------------------------------------------------------------------
/readme/godaddy3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/godaddy3.gif
--------------------------------------------------------------------------------
/readme/godaddydnsmanagement.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/godaddydnsmanagement.png
--------------------------------------------------------------------------------
/readme/namecheap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namecheap.png
--------------------------------------------------------------------------------
/readme/namecheap1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namecheap1.png
--------------------------------------------------------------------------------
/readme/namecheap2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namecheap2.png
--------------------------------------------------------------------------------
/readme/namecheap3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namecheap3.png
--------------------------------------------------------------------------------
/readme/namecheap4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namecheap4.png
--------------------------------------------------------------------------------
/readme/namesilo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namesilo.jpg
--------------------------------------------------------------------------------
/readme/namesilo1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namesilo1.jpg
--------------------------------------------------------------------------------
/readme/namesilo2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namesilo2.jpg
--------------------------------------------------------------------------------
/readme/namesilo3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/namesilo3.jpg
--------------------------------------------------------------------------------
/readme/shoutrrr_version_test.go:
--------------------------------------------------------------------------------
1 | package readme
2 |
3 | import (
4 | "os"
5 | "regexp"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | "golang.org/x/mod/modfile"
11 | )
12 |
13 | var regexShoutrrrURL = regexp.MustCompile(`https://containrrr.dev/shoutrrr/v[0-9.]+/services/overview/`)
14 |
15 | func Test_Readme_Shoutrrr_Version(t *testing.T) {
16 | t.Parallel()
17 |
18 | goModBytes, err := os.ReadFile("../go.mod")
19 | require.NoError(t, err)
20 |
21 | goMod, err := modfile.Parse("../go.mod", goModBytes, nil)
22 | require.NoError(t, err)
23 |
24 | shoutrrrVersion := ""
25 | for _, require := range goMod.Require {
26 | if require.Mod.Path != "github.com/containrrr/shoutrrr" {
27 | continue
28 | }
29 | shoutrrrVersion = require.Mod.Version
30 | }
31 | require.NotEmpty(t, shoutrrrVersion)
32 |
33 | // Remove bugfix suffix from version
34 | lastDot := strings.LastIndex(shoutrrrVersion, ".")
35 | require.GreaterOrEqual(t, lastDot, 0)
36 | urlShoutrrrVersion := shoutrrrVersion[:lastDot]
37 |
38 | expectedShoutrrrURL := "https://containrrr.dev/shoutrrr/" +
39 | urlShoutrrrVersion + "/services/overview/"
40 |
41 | readmeBytes, err := os.ReadFile("../README.md")
42 | require.NoError(t, err)
43 | readmeString := string(readmeBytes)
44 |
45 | readmeShoutrrrURLs := regexShoutrrrURL.FindAllString(readmeString, -1)
46 | require.NotEmpty(t, readmeShoutrrrURLs)
47 |
48 | for _, readmeShoutrrrURL := range readmeShoutrrrURLs {
49 | if readmeShoutrrrURL != expectedShoutrrrURL {
50 | t.Errorf("README.md contains outdated shoutrrr URL: %s should be %s",
51 | readmeShoutrrrURL, expectedShoutrrrURL)
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/readme/webui-desktop.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/webui-desktop.gif
--------------------------------------------------------------------------------
/readme/webui-mobile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qdm12/ddns-updater/20ac11075322b1f0bee1f9b91913406777721ae8/readme/webui-mobile.png
--------------------------------------------------------------------------------