24 Commits

Author SHA1 Message Date
5a1ec0ecdc feat: codeql
Some checks failed
CI / test (pull_request) Waiting to run
CodeQL Analysis / codeql-analysis (pull_request) Failing after 8m54s
2025-01-01 12:49:23 +01:00
ad0932f4aa docs: log level (#23)
All checks were successful
CD / test (push) Successful in 29s
CD / Build and push (push) Successful in 4m16s
Reviewed-on: #23
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2025-01-01 12:25:14 +01:00
fff36bf807 feat: concurrently check and update all domains (#24)
All checks were successful
CD / test (push) Successful in 2m32s
CD / Build and push (push) Successful in 2m53s
Reviewed-on: #24
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-30 17:37:49 +01:00
1c725993f5 fix: increase resilliency (#22)
All checks were successful
CD / test (push) Successful in 47s
CD / Build and push (push) Successful in 3m2s
Reviewed-on: #22
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 20:44:19 +01:00
3ad5b1ec0e feat: logging (#20)
All checks were successful
CD / test (push) Successful in 52s
CD / Build and push (push) Successful in 15m10s
Reviewed-on: #20
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 19:52:21 +01:00
1ea43ac4cf chore: upgrade from go 1.20 to 1.23 (#21)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 3m1s
Reviewed-on: #21
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 17:09:34 +01:00
b781399b47 fix: only query relevant records when looking up record ids on Ionos API (#19)
All checks were successful
CD / test (push) Successful in 5m33s
CD / Build and push (push) Successful in 4m7s
Reviewed-on: #19
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 16:55:26 +01:00
40a41ce7ca fix: align go version of modfile with dockerfile (#18)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 2m47s
Reviewed-on: #18
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 12:32:51 +01:00
ef57421268 ci: check format in CI pipeline (#17)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 2m41s
Reviewed-on: #17
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 19:37:47 +01:00
a01c328ac5 docs: mode feature (#16)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 3m44s
Reviewed-on: #16
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 14:59:51 +01:00
ac786f533d feat: add mode selecting (#15)
All checks were successful
CD / test (push) Successful in 44s
CD / Build and push (push) Successful in 3m5s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 14:17:46 +01:00
e84a409d82 chore: cleanup (#9)
All checks were successful
CD / test (push) Successful in 40s
CD / Build and push (push) Successful in 2m28s
2024-08-11 13:08:06 +02:00
637a789897 fix: revert cicd changes (#14)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 4m4s
2024-08-11 12:36:33 +02:00
a96418fb3d docs: document config directory (#10)
All checks were successful
CI / test (push) Successful in 1m39s
2024-08-11 12:31:19 +02:00
e0412f87b6 fix: cicd workflow interdependency (#13)
All checks were successful
CI / test (push) Successful in 1m38s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 12:28:30 +02:00
1a25992f03 fix: cicd dependency between workflow (#12)
All checks were successful
CI / test (push) Successful in 38s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 12:14:38 +02:00
62a05d5e1e refactor: cicd cd workflow depends on ci workflow success (#11) 2024-08-11 12:03:10 +02:00
7bb1e9ca08 feat: notification provider (#8)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 3m48s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 11:52:51 +02:00
b8bdcaa35e fix: gocron DoWithJobDetails wrong job function (#7)
All checks were successful
CD / Build and push (push) Successful in 4m13s
CD / test (push) Successful in 6m55s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 09:36:05 +02:00
432974b0d0 fix: regression error in CD pipeline (#6)
All checks were successful
CD / test (push) Successful in 16s
CD / Build and push (push) Successful in 3m23s
Fix regression error introduced when refactoring CD pipeline.

Reviewed-on: #6
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-04-01 21:51:54 +02:00
380d7eaa4e refactor: CI/CD pipelines (#5)
Some checks failed
CD / test (push) Successful in 43s
CD / Build and push (push) Failing after 26s
Reviewed-on: #5
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-04-01 21:47:18 +02:00
74c38313a2 feat: add docker build steps (#4)
All checks were successful
CI / test (push) Successful in 27s
Introduce CD pipeline, running after CI workflow runs successfully on main branch.
Builds and pushes Docker image to registry.

Reviewed-on: #4
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-04-01 20:48:32 +02:00
31ca7b1065 feat: add CI pipeline (#3)
All checks were successful
CI / test (push) Successful in 18m46s
Addition of unit test in CI pipeline.
Branch protection for merges to main setup, requiring "CI / test (pull_request)".

Reviewed-on: #3
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-04-01 15:38:08 +02:00
99361a5ccd feat: mvp (#1)
MVP including features:
* Detect IP change
* Configurable/generic external IP provider
* Configurable/generic DNS provider
* Impl. of plain external IP provider
* Impl. of Ionos DNS provider

Reviewed-on: #1
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-04-01 11:50:27 +02:00
33 changed files with 2177 additions and 0 deletions

84
.gitea/workflows/cd.yaml Normal file
View File

@@ -0,0 +1,84 @@
name: CD
on:
push:
branches:
- main
env:
DOCKER_REGISTRY: gitea.t000-n.de
jobs:
test:
name: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Create cache key
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go
with:
patterns: |
go.mod
go.sum
- name: cache go
id: cache-go
uses: actions/cache@v4
with:
path: |
/go_path
/go_cache
key: go_path-${{ steps.hash-go.outputs.hash }}
restore-keys: |-
go_cache-${{ steps.hash-go.outputs.hash }}
- name: build
run: make build
- name: test
run: make test
build_and_push:
name: Build and push
needs:
- test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Get Metadata
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:latest

44
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,44 @@
name: CI
on:
pull_request:
env:
GOPATH: /go_path
GOCACHE: /go_cache
RUNNER_TOOL_CACHE: /toolcache
jobs:
test:
name: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Create cache key
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go
with:
patterns: |
go.mod
go.sum
- name: cache go
id: cache-go
uses: actions/cache@v4
with:
path: |
/go_path
/go_cache
key: go_path-${{ steps.hash-go.outputs.hash }}
restore-keys: |-
go_cache-${{ steps.hash-go.outputs.hash }}
- name: build
run: make build
- name: test
run: make test
- name: check:format
run: make check-format

View File

@@ -0,0 +1,23 @@
name: CodeQL Analysis
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
codeql-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: "go"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

2
.gitignore vendored
View File

@@ -21,3 +21,5 @@
# Go workspace file # Go workspace file
go.work go.work
.vscode
config.yaml

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM golang:1.23-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main .
CMD ["./main"]

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
test:
go test ./pkg/... -coverprofile=coverage.out
build:
go build
lint:
golint ./...
run:
make build
./realdyndns
format:
gofmt -w .
check-format:
@OUTPUT=$$(gofmt -l .); \
if [ -n "$$OUTPUT" ]; then \
echo "Formatter failed for:"; \
echo "$$OUTPUT"; \
exit 1; \
fi

108
README.md
View File

@@ -1,2 +1,110 @@
# realDynDNS # realDynDNS
RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries.
This service requires your DNS provider to expose an API that allows your DNS entries to be changed.
A service that provides your current external IP is also required.
## Configuration
The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains.
Configuration of the IP provider and the DNS provider is mandatory.
The `check_interval` is mandatory and follows the cron syntax including seconds. It defines how often the IP is checked and the DNS entries are updated. If you don't own the external ip provider, please be nice and don't set the interval too low.
You may configure multiple top level domains with multiple subdomains. If you also want the top level domain to be updated, add it to the subdomains list with an `@`.
```yaml
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns
notification_provider:
type: gotify
config:
url: <your-gotify-host>
token: <your-token>
priority: 0
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info
```
The config file is expected to be in the same directory as the binary and called `config.yaml`. For the OCR image, the root directory is `/app`.
## DNS Providers
The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported:
### IONOS
IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted).
```yaml
api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns
```
## External IP Providers
The external IP provider is used to get your current external IP. Currently the following providers are supported:
### Plain
Any provider that returns your IP as plain text can be used. The following configuration is required:
```yaml
url: <your-providers-URL>
```
Examples for providers are:
- https://ifconfig.me
- https://api.ipify.org
## Notification Providers
The notification provider is used to send notifications whena IP address changes and a DNS record is updated.
### Console
The console notification provider is used to print the notification to the console. This is the default notification provider.
### Gotify
The Gotify notification provider is used to send notifications to a Gotify server.
```yaml
url: <your-gotify-host>
token: <your-token>
priority: 0
```
The priority must be between 0 and 4.
## Mode Selection
Two modes are available:
### RunOnce
The RunOnce mode is used to run the application once and exit. This is useful when providing your own external scheduler, like cron.
Set the `mode` to `RunOnce`.
### Scheduled
The Scheduled mode is used to run the application in a scheduled interval.
Set the `mode` to `Scheduled` and provide a cron expression for the `check_interval`.

24
config.example.yaml Normal file
View File

@@ -0,0 +1,24 @@
---
ip_provider:
type: plain
config:
url: https://api.ipify.org
dns_provider:
type: ionos
config:
api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns
notification_provider:
type: gotify
config:
url: <your-gotify-host>
token: <your-token>
priority: 0
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info

15
go.mod Normal file
View File

@@ -0,0 +1,15 @@
module realdnydns
go 1.23
require (
github.com/go-co-op/gocron v1.37.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
)

45
go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

142
main.go Normal file
View File

@@ -0,0 +1,142 @@
package main
import (
"fmt"
"log/slog"
"os"
"strings"
"realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider"
ionos "realdnydns/pkg/dnsProvider/ionos"
"realdnydns/pkg/externalIpProvider"
plainExternalIpProvider "realdnydns/pkg/externalIpProvider/plain"
"realdnydns/pkg/notificationProvider"
notificationProviderConsole "realdnydns/pkg/notificationProvider/console"
gotify "realdnydns/pkg/notificationProvider/gotify"
"realdnydns/pkg/realDynDns"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
configClient := config.Config{}
err := configClient.Load("config.yaml")
if err != nil {
logger.Error("Failed to load config file", slog.String("error", err.Error()))
panic(err)
}
if configClient.LogLevel != "" {
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.Level(config.LogLevelMap[strings.ToLower(configClient.LogLevel)]),
}))
}
var externalIpProvider externalIpProvider.ExternalIpProvider
switch configClient.ExternalIPProvider.Type {
case "plain":
logger.Info("Using plain external IP provider", slog.String("external_ip_provider", "plain"))
var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig
err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig)
if err != nil {
logger.Error("Failed to create config",
slog.String("external_ip_provider", "plain"),
slog.String("error", err.Error()),
)
panic(err)
}
externalIpProvider, err = plainExternalIpProvider.New(plainConfig)
if err != nil {
logger.Error("Failed to create plain external IP provider",
slog.String("external_ip_provider", "plain"),
slog.String("error", err.Error()),
)
panic(err)
}
default:
logger.Error("Unknown external IP provider", slog.String("external_ip_provider", configClient.ExternalIPProvider.Type))
panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type))
}
var dnsProvider dnsProvider.DNSProvider
switch configClient.DNSProvider.Type {
case "ionos":
logger.Info("Using IONOS DNS provider", slog.String("dns_provider", "ionos"))
var ionosConfig ionos.IONOSConfig
err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig)
if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err)
}
dnsProvider, err = ionos.NewIonos(&ionosConfig)
if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err)
}
default:
logger.Error("Unknown DNS provider", slog.String("dns_provider", configClient.DNSProvider.Type))
panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type))
}
var notificationProvider notificationProvider.NotificationProvider
switch configClient.NotificationProvider.Type {
case "gotify":
logger.Info("Using Gotify notification provider", slog.String("notification_provider", "gotify"))
var gotifyConfig gotify.NotificationProviderImplGotifyConfig
err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig)
if err != nil {
panic(err)
}
notificationProvider, err = gotify.New(gotifyConfig)
if err != nil {
logger.Error("Failed to create Gotify notification provider",
slog.String("notification_provider", "gotify"),
slog.String("error", err.Error()),
)
panic(err)
}
default:
logger.Info("Using console notification provider", slog.String("notification_provider", "console"))
notificationProvider = notificationProviderConsole.New()
}
rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains, logger.With(slog.String("service", "realDynDns")))
switch configClient.Mode {
case config.ScheduledMode:
logger.Info("Running in scheduled mode", slog.String("interval", configClient.CheckInterval))
schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval)
if err != nil {
logger.Error("Failed to create scheduler", slog.String("error", err.Error()))
panic(err)
}
logger.Info("Next run:", slog.String("time", job.NextRun().String()))
schedule.StartBlocking()
case config.RunOnceMode:
logger.Info("Running in run once mode")
_, err := rdd.RunOnce()
if err != nil {
logger.Error("Failed to run once", slog.String("error", err.Error()))
panic(err)
}
}
}

9
model/common/common.go Normal file
View File

@@ -0,0 +1,9 @@
package common
type ARecord struct {
Domain string
IP string
TTL int
Prio int
Disabled bool
}

View File

@@ -0,0 +1,15 @@
ip_provider:
type: plain
config:
url: https://example.com
dns_provider:
type: ionos
config:
api_key: exampleapikey
base_url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
check_interval: 0 0 * * * *
mode: RunOnce

View File

@@ -0,0 +1,2 @@
mode: "InvalidMode"
check_interval: "5m"

View File

@@ -0,0 +1,3 @@
mode: "Scheduled"
check_interval: "5m"
- invalid_content

View File

@@ -0,0 +1,3 @@
mode: "Scheduled"
ip_provider:
type: "plain"

91
pkg/config/config.go Normal file
View File

@@ -0,0 +1,91 @@
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Mode string `yaml:"mode"`
ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"`
DNSProvider DNSProviderConfig `yaml:"dns_provider"`
NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"`
LogLevel string `yaml:"log_level"`
}
const (
RunOnceMode = "RunOnce"
ScheduledMode = "Scheduled"
)
var LogLevelMap = map[string]slog.Level{
"debug": slog.LevelDebug,
"info": slog.LevelInfo,
"warn": slog.LevelWarn,
"error": slog.LevelError,
}
func isValidLogLevel(level string) bool {
_, ok := LogLevelMap[strings.ToLower(level)]
return ok
}
type DomainConfig struct {
TLD string `yaml:"tld"`
Subdomains []string `yaml:"subdomains"`
}
type ExternalIpProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
type DNSProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
type NotificationProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
func (c *Config) Load(filePath string) error {
inputConfig, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(inputConfig, c); err != nil {
return fmt.Errorf("failed to unmarshal config file: %w", err)
}
if err := c.validate(); err != nil {
return fmt.Errorf("failed to validate config: %w", err)
}
return nil
}
func (c *Config) validate() error {
if c.Mode != RunOnceMode && c.Mode != ScheduledMode {
return errors.New("mode must be one of 'RunOnce' or 'Scheduled'")
}
if c.Mode == ScheduledMode && c.CheckInterval == "" {
return errors.New("check interval must be set when mode is 'Scheduled'")
}
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
return fmt.Errorf("log level must be one of 'debug', 'info', 'warn', 'error', but got %s", c.LogLevel)
}
return nil
}

52
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,52 @@
package config
import (
"fmt"
"testing"
)
func testFactoryFileRelatedError(fileName string, expectedErrorText string) func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load(fmt.Sprintf("./__mocks__/%s", fileName))
want := err != nil && err.Error() == expectedErrorText
if !want {
t.Fatalf("Expected error message %s, but got %s", expectedErrorText, err.Error())
}
}
}
func TestLoad(t *testing.T) {
t.Run("Can find file", testLoadCanFindFile())
t.Run("Cannot find file", testFactoryFileRelatedError(
"nonexistent.yaml",
"failed to read config file: open ./__mocks__/nonexistent.yaml: no such file or directory",
))
t.Run("Missing CheckInterval in Scheduled mode", testFactoryFileRelatedError(
"testLoadMissingCheckInterval.yaml",
"failed to validate config: check interval must be set when mode is 'Scheduled'",
))
t.Run("Invalid mode", testFactoryFileRelatedError(
"testLoadInvalidMode.yaml",
"failed to validate config: mode must be one of 'RunOnce' or 'Scheduled'",
))
t.Run("Invalid YAML", testFactoryFileRelatedError(
"testLoadInvalidYAML.yaml",
"failed to unmarshal config file: yaml: line 2: did not find expected key",
))
}
func testLoadCanFindFile() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load("./__mocks__/testLoadCanFindFile.yaml")
want := err == nil && c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.Mode == "RunOnce"
if !want || err != nil {
t.Fatalf("Failed to load config file, expected no errors but got: %s", err)
}
}
}

View File

@@ -0,0 +1,16 @@
---
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: exampleAPIKey
base_url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *

View File

@@ -0,0 +1,11 @@
package dnsProvider
import (
"net"
"realdnydns/model/common"
)
type DNSProvider interface {
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetRecord(tld string, subdomain string) (*common.ARecord, error)
}

View File

@@ -0,0 +1,224 @@
package ionosAPI
import (
"bytes"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"realdnydns/model/common"
)
/**
* Docs: https://developer.hosting.ionos.com/docs/dns
*/
type IonosAPI interface {
GetARecord(tld string, subdomain string) (*common.ARecord, error)
SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetZoneId(tld string) (string, error)
GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error)
HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error)
}
type IonosAPIImpl struct {
APIKey string
BaseURL string
HTTPClient *http.Client
}
type ZonesResponse []struct {
Name string `json:"name"`
Id string `json:"id"`
Type string `json:"type"`
}
type ZoneResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Records []RecordResponse `json:"records"`
}
type RecordResponse struct {
Id string `json:"id"`
Name string `json:"name"`
RootName string `json:"rootName"`
Type string `json:"type"`
Content string `json:"content"`
ChangeDate string `json:"changeDate"`
TTL int `json:"ttl"`
Prio int `json:"prio"`
Disabled bool `json:"disabled"`
}
type ChangeRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL int `json:"ttl"`
Prio int `json:"prio"`
Disabled bool `json:"disabled"`
}
type ChangeRecordRequest struct {
Content string `json:"content"`
TTL int `json:"ttl"`
Prio int `json:"prio"`
Disabled bool `json:"disabled"`
}
func New(APIKey string, BaseURL string) IonosAPI {
return &IonosAPIImpl{
APIKey: APIKey,
BaseURL: BaseURL,
HTTPClient: &http.Client{},
}
}
func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
requestUrl, _ := url.Parse(i.BaseURL + path)
query := requestUrl.Query()
for key, value := range queryParams {
query.Add(key, value)
}
requestUrl.RawQuery = query.Encode()
req, err := http.NewRequest(method, requestUrl.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("X-API-Key", i.APIKey)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
return i.HTTPClient.Do(req)
}
func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
res, err := i.HttpCall("GET", "/v1/zones", nil, nil)
if err != nil {
return "", err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
zones := []ZoneResponse{}
json.Unmarshal(responseBody, &zones)
for _, z := range zones {
if z.Name == tld {
return z.Id, nil
}
}
return "", errors.New("zone not found")
}
func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
var domain string
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType})
if err != nil {
return "", err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
zone := ZoneResponse{}
json.Unmarshal(responseBody, &zone)
for _, record := range zone.Records {
if record.Type == recordType && record.Name == domain {
return record.Id, nil
}
}
return "", errors.New("record not found")
}
func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
zoneId, err := i.GetZoneId(tld)
if err != nil {
return nil, err
}
recordId, err := i.GetRecordId(zoneId, tld, subdomain, "A")
if err != nil {
return nil, err
}
changeRecordRequest, err := json.Marshal(ChangeRecordRequest{
Content: ip.String(),
TTL: ttl,
Prio: prio,
Disabled: false,
})
if err != nil {
return nil, err
}
res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil)
if err != nil {
return nil, err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
if res.StatusCode != 200 {
return nil, errors.New("error updating record")
}
changeRecord := ChangeRecord{}
json.Unmarshal(responseBody, &changeRecord)
return &common.ARecord{
Domain: changeRecord.Name,
IP: changeRecord.Content,
TTL: changeRecord.TTL,
Prio: changeRecord.Prio,
Disabled: changeRecord.Disabled,
}, nil
}
func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARecord, error) {
zoneId, err := ionos.GetZoneId(tld)
if err != nil {
return nil, err
}
recordId, err := ionos.GetRecordId(zoneId, tld, subdomain, "A")
if err != nil {
return nil, err
}
res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil)
if err != nil {
return nil, err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
record := RecordResponse{}
json.Unmarshal(responseBody, &record)
return &common.ARecord{
Domain: record.Name,
IP: record.Content,
TTL: record.TTL,
Prio: record.Prio,
Disabled: record.Disabled,
}, nil
}

View File

@@ -0,0 +1,285 @@
package ionosAPI
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
)
func utilMockServerImpl() func(w http.ResponseWriter, r *http.Request) {
zonesResponse := []ZoneResponse{
{
Name: "example.com",
Id: "1234567890",
Type: "NATIVE",
},
{
Name: "notTheExample.org",
Id: "0987654321",
Type: "SLAVE",
},
}
zonesResponseJson, _ := json.Marshal(zonesResponse)
recordResponseSub := RecordResponse{
Id: "abcdefghij",
Name: "example.com",
RootName: "example.com",
Type: "A",
Content: "127.0.0.1",
ChangeDate: "2019-12-09T13:04:25.772Z",
TTL: 300,
Prio: 0,
Disabled: false,
}
recordResponseSubJson, _ := json.Marshal(recordResponseSub)
recordResponseTLD := RecordResponse{
Id: "jihgfedcba",
Name: "sub.example.com",
RootName: "example.com",
Type: "A",
Content: "127.0.0.2",
ChangeDate: "2019-12-09T13:04:25.772Z",
TTL: 300,
Prio: 0,
Disabled: false,
}
recordResponseTLDJson, _ := json.Marshal(recordResponseTLD)
zoneResponse := ZoneResponse{
Id: "1234567890",
Name: "example.com",
Type: "NATIVE",
Records: []RecordResponse{
recordResponseSub,
recordResponseTLD,
},
}
zoneResponseJson, _ := json.Marshal(zoneResponse)
changeRecord := ChangeRecord{
Name: "sub.example.com",
Type: "A",
Content: "127.0.0.1",
TTL: 300,
Prio: 0,
Disabled: false,
}
changeRecordJson, _ := json.Marshal(changeRecord)
return func(w http.ResponseWriter, r *http.Request) {
var response []byte
if r.Method == "GET" {
if r.RequestURI == "/v1/zones" {
response = zonesResponseJson
} else if r.RequestURI == "/v1/zones/1234567890?recordName=example.com&recordType=A" ||
r.RequestURI == "/v1/zones/1234567890?recordName=sub.example.com&recordType=A" {
response = zoneResponseJson
} else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" {
response = recordResponseSubJson
} else if r.RequestURI == "/v1/zones/1234567890/records/jihgfedcba" {
response = recordResponseTLDJson
}
} else if r.Method == "PUT" {
response = changeRecordJson
} else {
response = []byte("404")
}
w.Write(response)
}
}
func TestHttpCall(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
defer mockServer.Close()
ionosAPI := New("dummyKey", mockServer.URL)
t.Run("returns response for GET request", testHttpCallGet(ionosAPI))
t.Run("returns response for PUT request", testHttpCallPut(ionosAPI))
t.Run("returns error for non existing endpoint", testHttpCallNonExistingEndpoint())
}
func testHttpCallGet(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
res, err := api.HttpCall("GET", "/v1/zones", nil, nil)
if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err)
}
if res.StatusCode != 200 {
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode)
}
}
}
func testHttpCallPut(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil, nil)
if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err)
}
if res.StatusCode != 200 {
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode)
}
}
}
func testHttpCallNonExistingEndpoint() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer mockServer.Close()
api := New("dummyKey", mockServer.URL)
res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil, nil)
if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err)
}
if res.StatusCode != 404 {
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 404", res.StatusCode)
}
}
}
func TestGetZoneId(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
ionosAPI := New("dummyKey", mockServer.URL)
t.Run("returns zoneId for tracked domain", testGetZoneIdSuccess(ionosAPI))
t.Run("returns error for non tracked domain", testGetZoneIdNoMatch(ionosAPI))
}
func testGetZoneIdSuccess(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
zoneId, err := api.GetZoneId("example.com")
if err != nil {
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
}
if zoneId != "1234567890" {
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of 1234567890", zoneId)
}
}
}
func testGetZoneIdNoMatch(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
zoneId, err := api.GetZoneId("nonTrackedDomain.com")
if err == nil || zoneId != "" {
t.Fatalf("GetZoneId() did not return an error for a non tracked domain")
}
}
}
func TestGetRecordId(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
defer mockServer.Close()
ionosAPI := New("dummyKey", mockServer.URL)
t.Run("returns zoneId for top level domain", testGetRecordIdForTopLevelDomain(ionosAPI))
t.Run("returns zoneId for subdomain", testGetRecordIdForSubdomain(ionosAPI))
t.Run("returns error for untracked subdomain", testGetRecordIdWithUntrackedSubdomain(ionosAPI))
}
func testGetRecordIdForTopLevelDomain(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
recordId, err := api.GetRecordId("1234567890", "example.com", "", "A")
if err != nil {
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
}
if recordId != "abcdefghij" {
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of abcdefghij", recordId)
}
}
}
func testGetRecordIdForSubdomain(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
recordId, err := api.GetRecordId("1234567890", "example.com", "sub", "A")
if err != nil {
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
}
if recordId != "jihgfedcba" {
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of jihgfedcba", recordId)
}
}
}
func testGetRecordIdWithUntrackedSubdomain(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) {
recordId, err := api.GetRecordId("1234567890", "example.com", "untrackedSub", "A")
if err == nil && recordId == "" {
t.Fatalf("GetZoneId() did not return an error for a non tracked domain")
}
}
}
func TestGetARecord(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
defer mockServer.Close()
ionosAPI := New("dummyKey", mockServer.URL)
t.Run("returns A record for top level domain", testGetARecordFor(ionosAPI, "example.com", ""))
t.Run("returns A record for subdomain", testGetARecordFor(ionosAPI, "example.com", "sub"))
}
func testGetARecordFor(api IonosAPI, tld string, subdomain string) func(t *testing.T) {
return func(t *testing.T) {
var domain string
if subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
record, err := api.GetARecord(tld, subdomain)
if err != nil {
t.Fatalf("GetARecord() returned unexpected error: %v", err)
}
if record.Domain != domain {
t.Fatalf("GetARecord() returned unexpected record for: %v instead of %v", record.Domain, domain)
}
}
}
func TestSetARecord(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
defer mockServer.Close()
ionosAPI := New("dummyKey", mockServer.URL)
changedARecord, err := ionosAPI.SetARecord("example.com", "sub", net.ParseIP("127.0.0.1"), 300, 0, false)
if err != nil {
t.Fatalf("SetARecord() returned unexpected error: %v", err)
}
if changedARecord.Domain != "sub.example.com" {
t.Fatalf("SetARecord() returned unexpected record for: %v instead of sub.example.com", changedARecord.Domain)
}
}

View File

@@ -0,0 +1,40 @@
package ionosDnsProvider
import (
"errors"
"net"
"realdnydns/model/common"
"realdnydns/pkg/dnsProvider"
ionosAPI "realdnydns/pkg/dnsProvider/ionos/api"
)
type IONOS struct {
API ionosAPI.IonosAPI
}
type IONOSConfig struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
}
func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) {
if config.APIKey == "" {
return nil, errors.New("api_key is required")
}
if config.BaseURL == "" {
return nil, errors.New("base_url is required")
}
return &IONOS{
ionosAPI.New(config.APIKey, config.BaseURL),
}, nil
}
func (ionos *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return ionos.API.SetARecord(tld, subdomain, ip, ttl, prio, disabled)
}
func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
return ionos.API.GetARecord(tld, subdomain)
}

View File

@@ -0,0 +1,225 @@
package ionosDnsProvider_test
import (
"errors"
"io"
"net"
"net/http"
common "realdnydns/model/common"
ionosDnsProvider "realdnydns/pkg/dnsProvider/ionos"
"testing"
)
func TestNew(t *testing.T) {
//t.Run("Config cannot be decoded", testNewConfigCannotBeDecoded())
t.Run("API key is required", testNewAPIKeyIsRequired())
t.Run("Base URL is required", testNewBaseURLIsRequired())
t.Run("Valid config", testNewValidConfig())
}
func testNewAPIKeyIsRequired() func(*testing.T) {
return func(t *testing.T) {
config := ionosDnsProvider.IONOSConfig{
BaseURL: "https://api.ionos.com",
}
_, err := ionosDnsProvider.NewIonos(&config)
if err == nil {
t.Error("Expected error, got nil")
}
}
}
func testNewBaseURLIsRequired() func(*testing.T) {
return func(t *testing.T) {
config := ionosDnsProvider.IONOSConfig{
APIKey: "1234",
}
_, err := ionosDnsProvider.NewIonos(&config)
if err == nil {
t.Error("Expected error, got nil")
}
}
}
func testNewValidConfig() func(*testing.T) {
return func(t *testing.T) {
config := ionosDnsProvider.IONOSConfig{
APIKey: "1234",
BaseURL: "https://api.ionos.com",
}
_, err := ionosDnsProvider.NewIonos(&config)
if err != nil {
t.Errorf("Expected nil, got %v", err)
}
}
}
type MockIonosAPI struct {
SetARecordFunc func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetARecordFunc func(tld string, subdomain string) (*common.ARecord, error)
GetRecordIdFunc func(zoneId string, tld string, subdomain string, recordType string) (string, error)
GetZoneIdFunc func(tld string) (string, error)
HttpCallFunc func(method string, url string, body io.Reader) (*http.Response, error)
}
func (m *MockIonosAPI) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return m.SetARecordFunc(tld, subdomain, ip, ttl, prio, disabled)
}
func (m *MockIonosAPI) GetARecord(tld string, subdomain string) (*common.ARecord, error) {
return m.GetARecordFunc(tld, subdomain)
}
func (m *MockIonosAPI) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
return m.GetRecordIdFunc(zoneId, tld, subdomain, recordType)
}
func (m *MockIonosAPI) GetZoneId(tld string) (string, error) {
return m.GetZoneIdFunc(tld)
}
func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
return m.HttpCallFunc(method, url, body)
}
func TestUpdateRecord(t *testing.T) {
t.Run("API error", testUpdateRecordAPIError())
t.Run("API success", testUpdateRecordAPISuccess())
}
func testUpdateRecordAPIError() func(*testing.T) {
return func(t *testing.T) {
api := &MockIonosAPI{
SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return nil, errors.New("API error")
},
}
provider := ionosDnsProvider.IONOS{
API: api,
}
_, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false)
if err == nil {
t.Error("Expected error, got nil")
}
}
}
func testUpdateRecordAPISuccess() func(*testing.T) {
return func(t *testing.T) {
api := &MockIonosAPI{
SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return &common.ARecord{
Domain: "sub",
IP: "127.0.0.1",
TTL: 60,
Prio: 0,
Disabled: false,
}, nil
},
}
provider := ionosDnsProvider.IONOS{
API: api,
}
record, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false)
if err != nil {
t.Errorf("Expected nil, got %v", err)
}
if record.Domain != "sub" {
t.Errorf("Expected sub, got %s", record.Domain)
}
if record.IP != "127.0.0.1" {
t.Errorf("Expected 127.0.0.1, got %s", record.IP)
}
if record.TTL != 60 {
t.Errorf("Expected 60, got %d", record.TTL)
}
if record.Prio != 0 {
t.Errorf("Expected 0, got %d", record.Prio)
}
if record.Disabled {
t.Error("Expected false, got true")
}
}
}
func TestGetRecord(t *testing.T) {
t.Run("API error", testGetRecordAPIError())
t.Run("API success", testGetRecordAPISuccess())
}
func testGetRecordAPIError() func(*testing.T) {
return func(t *testing.T) {
api := &MockIonosAPI{
GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) {
return nil, errors.New("API error")
},
}
provider := ionosDnsProvider.IONOS{
API: api,
}
_, err := provider.GetRecord("example.com", "sub")
if err == nil {
t.Error("Expected error, got nil")
}
}
}
func testGetRecordAPISuccess() func(*testing.T) {
return func(t *testing.T) {
api := &MockIonosAPI{
GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) {
return &common.ARecord{
Domain: "sub",
IP: "127.0.0.1",
TTL: 60,
Prio: 0,
Disabled: false,
}, nil
},
}
provider := ionosDnsProvider.IONOS{
API: api,
}
record, err := provider.GetRecord("example.com", "sub")
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if record.Domain != "sub" {
t.Errorf("expected sub, got %s", record.Domain)
}
if record.IP != "127.0.0.1" {
t.Errorf("expected 127.0.0.1, got %s", record.IP)
}
if record.TTL != 60 {
t.Errorf("expected 60, got %d", record.TTL)
}
if record.Prio != 0 {
t.Errorf("expected 0, got %d", record.Prio)
}
if record.Disabled {
t.Error("expected false, got true")
}
}
}

View File

@@ -0,0 +1,9 @@
package externalIpProvider
import (
"net"
)
type ExternalIpProvider interface {
GetExternalIp() (net.IP, error)
}

View File

@@ -0,0 +1,56 @@
package externalIpProvider
import (
"errors"
"net"
"net/http"
"net/url"
externalIpProvider "realdnydns/pkg/externalIpProvider"
)
type ExternalIpProviderImplPlain struct {
IPProviderURL string
HTTPClient *http.Client
}
type PlainExternalIpProviderConfig struct {
Url string `yaml:"url"`
}
func New(config PlainExternalIpProviderConfig) (externalIpProvider.ExternalIpProvider, error) {
if config.Url == "" {
return nil, errors.New("url is required")
}
return &ExternalIpProviderImplPlain{
config.Url,
&http.Client{},
}, nil
}
func (p *ExternalIpProviderImplPlain) GetExternalIp() (net.IP, error) {
parsedUrl, err := url.Parse(p.IPProviderURL)
if err != nil {
return nil, err
}
res, err := p.HTTPClient.Get(parsedUrl.String())
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
return nil, errors.New("unexpected status code")
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
defer res.Body.Close()
parsedIp := net.ParseIP(string(responseBody))
if parsedIp == nil {
return nil, errors.New("unable to parse ip")
}
return parsedIp, nil
}

View File

@@ -0,0 +1,150 @@
package externalIpProvider
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNew(t *testing.T) {
t.Run("returns external ip provider", testNew())
t.Run("returns error when url is empty", testNewEmptyUrl())
}
func testNew() func(t *testing.T) {
return func(t *testing.T) {
provider, err := New(PlainExternalIpProviderConfig{
Url: "http://localhost",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
if provider == nil {
t.Fatalf("NewPlain() returned nil provider")
}
}
}
func testNewEmptyUrl() func(t *testing.T) {
return func(t *testing.T) {
_, err := New(PlainExternalIpProviderConfig{})
if err == nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
}
}
func TestGetExternal(t *testing.T) {
t.Run("returns external ip", testGetExternalIp())
t.Run("returns error when unable to parse ip", testGetExternalIpUnparsableIp())
t.Run("returns error when unexpected status code", testGetExternalIpUnexpectedStatusCode())
t.Run("returns error when unable to parse url", testGetExternalIpUnableToParseUrl())
t.Run("returns error when unable to get", testGetExternalIpUnableToGet())
}
func testGetExternalIp() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("127.0.0.1"))
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
ip, err := provider.GetExternalIp()
if err != nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
if ip.String() != "127.0.0.1" {
t.Fatalf("GetExternalIp() returned unexpected ip: %v instead of 127.0.0.1", ip)
}
}
}
func testGetExternalIpUnparsableIp() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not an ip"))
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnexpectedStatusCode() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnableToParseUrl() func(t *testing.T) {
return func(t *testing.T) {
provider, err := New(PlainExternalIpProviderConfig{
Url: "not a url !'§%&",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnableToGet() func(t *testing.T) {
return func(t *testing.T) {
// force error by using a non-responding url, beware of side effects
provider, err := New(PlainExternalIpProviderConfig{
Url: "http://localhost:1234",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}

View File

@@ -0,0 +1,16 @@
package notificationProviderConsole
import (
"fmt"
)
type NotificationProviderImplConsole struct{}
func New() *NotificationProviderImplConsole {
return &NotificationProviderImplConsole{}
}
func (p *NotificationProviderImplConsole) SendNotification(title string, message string) error {
fmt.Printf("%s: %s\n", title, message)
return nil
}

View File

@@ -0,0 +1,89 @@
package notificationProviderGotify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type NotificationProviderImplGotifyConfig struct {
Url string `yaml:"url"`
Token string `yaml:"token"`
Priority int `yaml:"priority"`
}
type NotificationProviderImplGotify struct {
Url url.URL
Token string
Priority int
HTTPClient *http.Client
}
func New(config NotificationProviderImplGotifyConfig) (*NotificationProviderImplGotify, error) {
if config.Url == "" {
return nil, fmt.Errorf("url is required")
}
url, err := url.Parse(config.Url)
if err != nil {
return nil, err
}
if config.Token == "" {
return nil, fmt.Errorf("token is required")
}
if config.Priority < 0 || config.Priority > 4 {
return nil, fmt.Errorf("priority must be between 0 and 4")
}
return &NotificationProviderImplGotify{
*url,
config.Token,
config.Priority,
&http.Client{},
}, nil
}
func (p *NotificationProviderImplGotify) SendNotification(title string, message string) error {
type GotifyMessage struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
}
messageJson, err := json.Marshal(GotifyMessage{
message,
title,
p.Priority,
})
if err != nil {
return err
}
messageUrl := p.Url
messageUrl = *messageUrl.JoinPath("message")
queryParams := messageUrl.Query()
queryParams.Add("token", p.Token)
messageUrl.RawQuery = queryParams.Encode()
req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := p.HTTPClient.Do(req)
if err != nil {
return err
}
if res.StatusCode != 200 {
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,81 @@
package notificationProviderGotify_test
import (
"net/http"
"net/http/httptest"
"testing"
gotify "realdnydns/pkg/notificationProvider/gotify"
)
func TestNew(t *testing.T) {
t.Run("URL is required", testNewEmptyUrl())
t.Run("Token is required", testNewEmptyToken())
t.Run("Priority must be between 0 and 4", testNewInvalidPriority())
t.Run("Sends POST request to url", testNewSendsPostRequest())
}
func testNewEmptyUrl() func(t *testing.T) {
return func(t *testing.T) {
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
Token: "1234",
Priority: 1,
})
if err.Error() != "url is required" {
t.Errorf("Expected error 'url is required', got %v", err)
}
}
}
func testNewEmptyToken() func(t *testing.T) {
return func(t *testing.T) {
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
Url: "http://localhost:1234",
Priority: 0,
})
if err.Error() != "token is required" {
t.Errorf("Expected error 'token is required', got %v", err)
}
}
}
func testNewInvalidPriority() func(t *testing.T) {
return func(t *testing.T) {
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
Url: "http://localhost:1234",
Token: "token",
Priority: 5,
})
if err.Error() != "priority must be between 0 and 4" {
t.Errorf("Expected error 'priority must be between 0 and 4', got %v", err)
}
}
}
func testNewSendsPostRequest() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
},
))
defer mockServer.Close()
provider, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
Url: mockServer.URL,
Token: "1234",
Priority: 0,
})
if err != nil {
t.Fatalf("New() returned unexpected error: %v", err)
}
err = provider.SendNotification("title", "message")
if err != nil {
t.Fatalf("SendNotification() returned unexpected error: %v", err)
}
}
}

View File

@@ -0,0 +1,5 @@
package notificationProvider
type NotificationProvider interface {
SendNotification(title string, message string) error
}

View File

@@ -0,0 +1,152 @@
package realDynDns
import (
"fmt"
"log/slog"
"sync"
"time"
"realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider"
"realdnydns/pkg/externalIpProvider"
"realdnydns/pkg/notificationProvider"
"github.com/go-co-op/gocron"
)
type ChangeDetector struct {
externalIpProvider externalIpProvider.ExternalIpProvider
dnsProvider dnsProvider.DNSProvider
notificationProvider notificationProvider.NotificationProvider
domains []config.DomainConfig
logger *slog.Logger
}
func New(
externalIpProvider externalIpProvider.ExternalIpProvider,
dnsProvider dnsProvider.DNSProvider,
notificationProvider notificationProvider.NotificationProvider,
domains []config.DomainConfig,
logger *slog.Logger,
) ChangeDetector {
return ChangeDetector{
externalIpProvider: externalIpProvider,
dnsProvider: dnsProvider,
notificationProvider: notificationProvider,
domains: domains,
logger: logger,
}
}
func (c *ChangeDetector) RunWithSchedule(checkInterval string) (*gocron.Scheduler, *gocron.Job, error) {
s := gocron.NewScheduler(time.UTC)
s.SingletonMode()
s.CronWithSeconds(checkInterval)
job, err := s.DoWithJobDetails(func(job gocron.Job) {
numberChanged, err := c.detectAndApplyChanges()
if err != nil {
panic(err)
}
fmt.Printf("Number of changes: %d\n", numberChanged)
fmt.Println("Next run:", job.NextRun())
})
if err != nil {
return nil, nil, err
}
return s, job, nil
}
func (c *ChangeDetector) RunOnce() (int, error) {
return c.detectAndApplyChanges()
}
func (c *ChangeDetector) detectAndApplyChanges() (int, error) {
c.logger.Info("Detecting and applying changes")
externalIp, err := c.externalIpProvider.GetExternalIp()
if err != nil {
c.logger.Error("Failed to retrieve external IP", slog.String("error", err.Error()))
return 0, err
}
var wg sync.WaitGroup
numberUpdatedChannel := make(chan int)
for _, domain := range c.domains {
for _, subdomain := range domain.Subdomains {
wg.Add(1)
go func(domain config.DomainConfig, subdomain string) {
defer wg.Done()
c.logger.Info("Checking record",
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
)
currentRecord, err := c.dnsProvider.GetRecord(domain.TLD, subdomain)
if err != nil {
c.logger.Error("Failed to retrieve record",
slog.String("error", err.Error()),
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
)
return
}
if currentRecord.IP != externalIp.String() {
c.logger.Info("Record has changed",
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
slog.String("current_ip", currentRecord.IP),
slog.String("external_ip", externalIp.String()),
)
err = c.notificationProvider.SendNotification(
fmt.Sprintf("Update %s.%s", subdomain, domain.TLD),
fmt.Sprintf("The IP of %s has changed from %s to %s", domain.TLD, currentRecord.IP, externalIp.String()),
)
if err != nil {
c.logger.Warn("Failed to send notification",
slog.String("error", err.Error()),
)
return
}
c.logger.Info("Updating record",
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
slog.String("current_ip", currentRecord.IP),
slog.String("external_ip", externalIp.String()),
)
_, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled)
if err != nil {
c.logger.Error("Failed to update record",
slog.String("error", err.Error()),
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
)
return
}
numberUpdatedChannel <- 1
}
}(domain, subdomain)
}
}
go func() {
wg.Wait()
close(numberUpdatedChannel)
}()
numberUpdated := 0
for v := range numberUpdatedChannel {
numberUpdated += v
}
c.logger.Info("Run completed", slog.Int("number_of_changes", numberUpdated))
return numberUpdated, nil
}

View File

@@ -0,0 +1,120 @@
package realDynDns
import (
"log/slog"
"net"
"realdnydns/model/common"
"realdnydns/pkg/config"
"testing"
)
type MockExternalIpProvider struct {
ExternalIp string
}
func (m *MockExternalIpProvider) GetExternalIp() (net.IP, error) {
return net.ParseIP(m.ExternalIp), nil
}
type MockDNSProvider interface {
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetRecord(tld string, subdomain string) (*common.ARecord, error)
}
type MockDNSProviderImpl struct {
GetRecordIpResponse string
UpdateRecordIpResponse string
}
func (m *MockDNSProviderImpl) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
return &common.ARecord{
IP: m.GetRecordIpResponse,
TTL: 10,
Prio: 20,
Disabled: false,
}, nil
}
func (m *MockDNSProviderImpl) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return &common.ARecord{
IP: m.UpdateRecordIpResponse,
TTL: 10,
Prio: 20,
Disabled: false,
}, nil
}
type MockedNotificationProvider struct{}
type MockedNotificationProviderImpl struct{}
func (m *MockedNotificationProviderImpl) SendNotification(title string, message string) error {
return nil
}
func TestDetectAndApplyChanges(t *testing.T) {
t.Run("with changes", testDetectAndApplyChangesWithChanges())
t.Run("without changes", testDetectAndApplyChangesWithoutChanges())
}
func testDetectAndApplyChangesWithChanges() func(t *testing.T) {
return func(t *testing.T) {
changeDetector := New(&MockExternalIpProvider{
ExternalIp: "127.0.0.1",
}, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.2",
UpdateRecordIpResponse: "127.0.0.1",
}, &MockedNotificationProviderImpl{},
[]config.DomainConfig{
{
TLD: "example.com",
Subdomains: []string{
"test",
"@",
},
},
},
slog.Default(),
)
numberUpdated, err := changeDetector.RunOnce()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if numberUpdated != 2 {
t.Errorf("expected numberUpdated to be 2, got %v", numberUpdated)
}
}
}
func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) {
return func(t *testing.T) {
changeDetector := New(&MockExternalIpProvider{
ExternalIp: "127.0.0.1",
}, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.1",
UpdateRecordIpResponse: "127.0.0.1",
}, &MockedNotificationProviderImpl{},
[]config.DomainConfig{
{
TLD: "example.com",
Subdomains: []string{
"test",
"@",
},
},
},
slog.Default(),
)
numberUpdated, err := changeDetector.RunOnce()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if numberUpdated != 0 {
t.Errorf("expected numberUpdated to be 0, got %v", numberUpdated)
}
}
}