├── .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 | [![DuckDNS Website](../readme/duckdns.png)](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 | drawing 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 | [![Namecheap Website](../readme/namecheap.png)](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 | ![https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns](../readme/namecheap1.png) 41 | 42 | 1. Select the following settings and create the *A + Dynamic DNS Record*: 43 | 44 | ![https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns](../readme/namecheap2.png) 45 | 46 | 1. Scroll down and turn on the switch for *DYNAMIC DNS* 47 | 48 | ![https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns](../readme/namecheap3.png) 49 | 50 | 1. The Dynamic DNS Password will appear, which is `0e4512a9c45a4fe88313bcc2234bf547` in this example. 51 | 52 | ![https://ap.www.namecheap.com/Domains/DomainControlPanel/example.com/advancedns](../readme/namecheap4.png) 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 --------------------------------------------------------------------------------