5 Commits

Author SHA1 Message Date
ed1f9c3ed1 chore: update example and main
All checks were successful
CI / test (pull_request) Successful in 1m3s
2024-12-23 14:13:29 +01:00
9478149a20 fix: ionos API content-type 2024-12-23 14:13:18 +01:00
3c8b6ef264 fix: gotify notification provider 2024-12-23 14:13:08 +01:00
6333b75775 feat: add mode selection 2024-12-23 14:12:54 +01:00
75754538df chore: run go mod tidy 2024-12-23 14:12:06 +01:00
19 changed files with 708 additions and 1990 deletions

View File

@@ -4,14 +4,6 @@ on:
push:
branches:
- main
paths:
- "go.mod"
- "go.sum"
- "**/*.go"
- "config.example.yaml"
- "Dockerfile"
- "Makefile"
workflow_dispatch:
env:
DOCKER_REGISTRY: gitea.t000-n.de
@@ -22,14 +14,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
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@264ae76b7e50173ce71ed7da4b48e5e517f3f9ec # v0.0.1
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go
with:
patterns: |
@@ -37,7 +29,7 @@ jobs:
go.sum
- name: cache go
id: cache-go
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5
uses: actions/cache@v4
with:
path: |
/go_path
@@ -52,90 +44,41 @@ jobs:
build_and_push:
name: Build and push
strategy:
matrix:
arch: [amd64, arm64]
needs:
- test
runs-on:
- ubuntu-latest
- linux_${{ matrix.arch }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@v2
- name: Login to Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
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@263435318d21b8e681c14492fe198d362a7d2c83 # v6
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.arch }}
platforms: |
linux/amd64
linux/arm64
push: true
provenance: false
build-args: GOARCH=${{ matrix.arch }}
tags: |
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-${{ matrix.arch }}
create_tag:
name: Create tag
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.new-tag }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- uses: https://gitea.t000-n.de/t.behrendt/conventional-semantic-git-tag-increment@9841c96e72d4fc1ecec620e91e213895341cce86 # 0.1.22
id: tag
with:
token: ${{ secrets.GITEA_TOKEN }}
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
- run: |
git tag ${{ steps.tag.outputs.new-tag }}
git push origin ${{ steps.tag.outputs.new-tag }}
- name: Set output
run: |
echo "tag=${{ steps.tag.outputs.new-tag }}" >> $GITHUB_OUTPUT
create_manifest:
name: Create manifest
needs:
- build_and_push
- create_tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- 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: Login to Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Create manifest
run: |
docker manifest create ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }} \
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-arm64
docker manifest push ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }}
${{ 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

View File

@@ -10,19 +10,17 @@ env:
jobs:
test:
name: test
runs-on:
- ubuntu-latest
- linux_amd64
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
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@264ae76b7e50173ce71ed7da4b48e5e517f3f9ec # v0.0.1
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go
with:
patterns: |
@@ -30,7 +28,7 @@ jobs:
go.sum
- name: cache go
id: cache-go
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5
uses: actions/cache@v4
with:
path: |
/go_path
@@ -42,12 +40,3 @@ jobs:
run: make build
- name: test
run: make test
- name: check:format
run: make check-format
- name: Upload test-coverage
uses: ChristopherHX/gitea-upload-artifact@v4
with:
name: realdnydns-test-coverage
path: |
lcov.info
retention-days: 1

2
.gitignore vendored
View File

@@ -23,5 +23,3 @@ go.work
.vscode
config.yaml
lcov.info
realdnydns

View File

@@ -1,14 +1,21 @@
FROM golang:1.25-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 as build
ARG GOARCH=amd64
FROM golang:1.21-alpine
# Set the Current Working Directory inside the container
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} \
go build -trimpath -ldflags="-s -w" -o main .
FROM gcr.io/distroless/static-debian12@sha256:4b2a093ef4649bccd586625090a3c668b254cfe180dee54f4c94f3e9bd7e381e
COPY --from=build /app/main /
CMD ["/main"]
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download
# Copy the source from the current directory to the Working Directory inside the container
COPY . .
# Build the Go app
RUN go build -o main .
# Command to run the executable
CMD ["./main"]

View File

@@ -1,31 +1,8 @@
test: test-unit test-race test-coverage
test-unit:
test:
go test ./pkg/... -coverprofile=coverage.out
test-race:
go test ./pkg/... -race
test-coverage:
go tool gcov2lcov -infile coverage.out > lcov.info
build:
go build
lint:
go tool 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
golint ./...

View File

@@ -1,12 +1,10 @@
# 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.
@@ -37,52 +35,40 @@ domains:
- "@"
- 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
@@ -92,19 +78,3 @@ 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`.

View File

@@ -8,10 +8,6 @@ dns_provider:
config:
api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns
# Optional: default TTL to use when patching records with missing TTL (default: 300)
# default_ttl: 300
# Optional: default priority to use when patching records with missing priority (default: 0)
# default_prio: 0
notification_provider:
type: gotify
config:
@@ -25,4 +21,3 @@ domains:
- www
check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info

15
go.mod
View File

@@ -1,26 +1,15 @@
module realdnydns
go 1.25.0
go 1.20
require (
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2
github.com/go-co-op/gocron v1.37.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jandelgado/gcov2lcov v1.1.1 // indirect
github.com/oapi-codegen/runtime v1.1.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/lint v0.0.0-20241112194109-818c5a804067 // indirect
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 // indirect
)
tool (
github.com/jandelgado/gcov2lcov
golang.org/x/lint/golint
)

31
go.sum
View File

@@ -1,9 +1,3 @@
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2 h1:EWz4kLLv7lSZx/F8K0/WxN5xeVNh7z4qbeDurZYBcNk=
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2/go.mod h1:HZfdMF7X9LK/3FP1jJVzMWbM5BcgcZg/Qwx2WClFipg=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
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=
@@ -13,9 +7,6 @@ github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISk
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/jandelgado/gcov2lcov v1.1.1 h1:CHUNoAglvb34DqmMoZchnzDbA3yjpzT8EoUvVqcAY+s=
github.com/jandelgado/gcov2lcov v1.1.1/go.mod h1:tMVUlMVtS1po2SB8UkADWhOT5Y5Q13XOce2AYU69JuI=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
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=
@@ -24,8 +15,6 @@ 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/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
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=
@@ -34,36 +23,18 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20241112194109-818c5a804067 h1:adDmSQyFTCiv19j015EGKJBoaa7ElV0Q1Wovb/4G7NA=
golang.org/x/lint v0.0.0-20241112194109-818c5a804067/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=

59
main.go
View File

@@ -2,9 +2,6 @@ package main
import (
"fmt"
"log/slog"
"os"
"strings"
"realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider"
@@ -18,84 +15,48 @@ import (
)
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 {
@@ -104,39 +65,29 @@ func main() {
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")))
rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains)
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()))
fmt.Println("Starting scheduler")
fmt.Println("Next run:", job.NextRun())
schedule.StartBlocking()
case config.RunOnceMode:
logger.Info("Running in run once mode")
_, err := rdd.RunOnce()
numberOfChanges, err := rdd.RunOnce()
if err != nil {
logger.Error("Failed to run once", slog.String("error", err.Error()))
panic(err)
}
fmt.Println("Number of changes:", numberOfChanges)
}
}

View File

@@ -3,9 +3,7 @@ package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"gopkg.in/yaml.v3"
)
@@ -17,7 +15,6 @@ type Config struct {
NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"`
LogLevel string `yaml:"log_level"`
}
const (
@@ -25,18 +22,6 @@ const (
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"`
@@ -71,7 +56,7 @@ func (c *Config) Load(filePath string) error {
return fmt.Errorf("failed to validate config: %w", err)
}
return nil
return nil;
}
func (c *Config) validate() error {
@@ -83,9 +68,5 @@ func (c *Config) validate() error {
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
return nil;
}

View File

@@ -0,0 +1,217 @@
package ionosAPI
import (
"bytes"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"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) (*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, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, i.BaseURL+url, 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)
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) {
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil)
if err != nil {
return "", err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
zone := ZoneResponse{}
json.Unmarshal(responseBody, &zone)
var domain string
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
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))
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)
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,284 @@
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" {
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)
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)
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)
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

@@ -1,30 +1,20 @@
package ionosDnsProvider
import (
"context"
"errors"
"net"
"net/http"
"realdnydns/model/common"
"realdnydns/pkg/dnsProvider"
"sync"
ionosDnsClient "gitea.t000-n.de/t.behrendt/ionosDnsClient"
ionosAPI "realdnydns/pkg/dnsProvider/ionos/api"
)
type IONOS struct {
client *ionosDnsClient.ClientWithResponses
zoneIdMap map[string]string
defaultTtl int
defaultPrio int
zoneIdMapMutex sync.Mutex
API ionosAPI.IonosAPI
}
type IONOSConfig struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
DefaultTTL *int `yaml:"default_ttl,omitempty"`
DefaultPrio *int `yaml:"default_prio,omitempty"`
}
func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) {
@@ -36,231 +26,15 @@ func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) {
return nil, errors.New("base_url is required")
}
options := []ionosDnsClient.ClientOption{
ionosDnsClient.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("X-API-Key", config.APIKey)
return nil
}),
}
client, err := ionosDnsClient.NewClientWithResponses(config.BaseURL, options...)
if err != nil {
return nil, err
}
// Set default values for TTL and Prio if not provided
defaultTtl := 300
if config.DefaultTTL != nil {
defaultTtl = *config.DefaultTTL
}
defaultPrio := 0
if config.DefaultPrio != nil {
defaultPrio = *config.DefaultPrio
}
return &IONOS{
client: client,
zoneIdMap: make(map[string]string),
defaultTtl: defaultTtl,
defaultPrio: defaultPrio,
ionosAPI.New(config.APIKey, config.BaseURL),
}, nil
}
func (i *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
zoneId, err := i.getZoneIdForTld(tld)
if err != nil {
return nil, err
}
domain := assembleTldSubdomainToDomain(tld, subdomain)
recordId, err := i.getRecordIdForDomain(zoneId, domain)
if err != nil {
return nil, err
}
ipString := ip.String()
res, err := i.client.UpdateRecordWithResponse(context.Background(), zoneId, recordId, ionosDnsClient.RecordUpdate{
Content: &ipString,
Ttl: &ttl,
Prio: &prio,
Disabled: &disabled,
})
if err != nil || res.StatusCode() != 200 {
return nil, errors.New("failed to update record")
}
if res.JSON200 == nil {
return nil, errors.New("record response is empty")
}
record := *res.JSON200
if record.Name == nil || record.Content == nil {
return nil, errors.New("record is empty")
}
// Handle optional fields with defaults
recordTtl := 0
if record.Ttl != nil {
recordTtl = *record.Ttl
}
recordPrio := 0
if record.Prio != nil {
recordPrio = *record.Prio
}
recordDisabled := false
if record.Disabled != nil {
recordDisabled = *record.Disabled
}
return &common.ARecord{
Domain: *record.Name,
IP: *record.Content,
TTL: recordTtl,
Prio: recordPrio,
Disabled: recordDisabled,
}, 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 (i *IONOS) getZoneIdForTld(tld string) (string, error) {
i.zoneIdMapMutex.Lock()
defer i.zoneIdMapMutex.Unlock()
if zoneId, ok := i.zoneIdMap[tld]; ok {
return zoneId, nil
}
res, err := i.client.GetZonesWithResponse(context.Background())
if err != nil {
return "", err
} else if res.StatusCode() != 200 {
return "", errors.New("failed to get zones")
}
if res.JSON200 == nil {
return "", errors.New("zones response is empty")
}
zones := *res.JSON200
for _, zone := range zones {
if *zone.Name == tld {
if zone.Id == nil || *zone.Id == "" {
return "", errors.New("zone id is empty")
}
i.zoneIdMap[tld] = *zone.Id
return *zone.Id, nil
}
}
return "", errors.New("no zone found")
}
var recordTypeA = "A"
func assembleTldSubdomainToDomain(tld string, subdomain string) string {
if subdomain == "@" || subdomain == "" {
return tld
}
return subdomain + "." + tld
}
func (i *IONOS) getRecordIdForDomain(zoneId string, domain string) (string, error) {
res, err := i.client.GetZoneWithResponse(context.Background(), zoneId, &ionosDnsClient.GetZoneParams{
RecordName: &domain,
RecordType: &recordTypeA,
})
if err != nil || res.StatusCode() != 200 {
return "", errors.New("failed to get zone")
}
if res.JSON200 == nil {
return "", errors.New("zone response is empty")
}
zone := *res.JSON200
for _, record := range *zone.Records {
if *record.Name == domain && *record.Type == ionosDnsClient.RecordTypes("A") {
return *record.Id, nil
}
}
return "", errors.New("no record found")
}
func (i *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
zoneId, err := i.getZoneIdForTld(tld)
if err != nil {
return nil, err
}
domain := assembleTldSubdomainToDomain(tld, subdomain)
recordId, err := i.getRecordIdForDomain(zoneId, domain)
if err != nil {
return nil, err
}
res, err := i.client.GetRecordWithResponse(context.Background(), zoneId, recordId)
if err != nil || res.StatusCode() != 200 || res.JSON200 == nil {
return nil, errors.New("failed to get record")
}
record := *res.JSON200
if record.Name == nil || *record.Name == "" || *record.Name != domain {
return nil, errors.New("record name does not match or is empty")
}
if record.Content == nil || *record.Content == "" {
return nil, errors.New("record content is empty")
}
needsUpdate := false
ttl := i.defaultTtl
if record.Ttl != nil {
ttl = *record.Ttl
} else {
needsUpdate = true
}
prio := i.defaultPrio
if record.Prio != nil {
prio = *record.Prio
} else {
needsUpdate = true
}
disabled := false
if record.Disabled != nil {
disabled = *record.Disabled
} else {
needsUpdate = true
}
// realDynDns requires every field to be set, so we need to update the record if any of the fields are missing
if needsUpdate {
ip := net.ParseIP(*record.Content)
if ip == nil {
return nil, errors.New("invalid IP address in record content")
}
updatedRecord, err := i.UpdateRecord(tld, subdomain, ip, ttl, prio, disabled)
if err != nil {
return nil, err
}
return updatedRecord, nil
}
return &common.ARecord{
Domain: *record.Name,
IP: *record.Content,
TTL: ttl,
Prio: prio,
Disabled: disabled,
}, nil
func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
return ionos.API.GetARecord(tld, subdomain)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ package externalIpProvider
import (
"errors"
"io"
"net"
"net/http"
"net/url"
@@ -41,15 +40,12 @@ func (p *ExternalIpProviderImplPlain) GetExternalIp() (net.IP, error) {
}
if res.StatusCode != 200 {
res.Body.Close()
return nil, errors.New("unexpected status code")
}
responseBody, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return nil, err
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
defer res.Body.Close()
parsedIp := net.ParseIP(string(responseBody))
if parsedIp == nil {

View File

@@ -2,8 +2,6 @@ package realDynDns
import (
"fmt"
"log/slog"
"sync"
"time"
"realdnydns/pkg/config"
@@ -19,7 +17,6 @@ type ChangeDetector struct {
dnsProvider dnsProvider.DNSProvider
notificationProvider notificationProvider.NotificationProvider
domains []config.DomainConfig
logger *slog.Logger
}
func New(
@@ -27,14 +24,12 @@ func New(
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,
}
}
@@ -63,90 +58,37 @@ func (c *ChangeDetector) RunOnce() (int, error) {
}
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)
var numberUpdated 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
return numberUpdated, err
}
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
return numberUpdated, err
}
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)
numberUpdated++
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
return numberUpdated, err
}
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

@@ -1,7 +1,6 @@
package realDynDns
import (
"log/slog"
"net"
"realdnydns/model/common"
"realdnydns/pkg/config"
@@ -73,9 +72,7 @@ func testDetectAndApplyChangesWithChanges() func(t *testing.T) {
"@",
},
},
},
slog.Default(),
)
})
numberUpdated, err := changeDetector.RunOnce()
if err != nil {
@@ -104,9 +101,7 @@ func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) {
"@",
},
},
},
slog.Default(),
)
})
numberUpdated, err := changeDetector.RunOnce()
if err != nil {

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>t.behrendt/renovate-configs:common",
"local>t.behrendt/renovate-configs:action"
],
"packageRules": [
{
"matchPackageNames": [
"golang",
"gomod",
"go"
],
"groupName": "go version",
"matchUpdateTypes": [
"major",
"minor",
"patch"
]
}
]
}