4 Commits

Author SHA1 Message Date
8971a70b7a fix: change requires to needs on build_and_push
Some checks failed
CI / test (pull_request) Has been cancelled
2024-08-11 12:33:00 +02:00
16acb0fe80 Revert "refactor: cicd cd workflow depends on ci workflow success (#11)"
This reverts commit 62a05d5e1e.
2024-08-11 12:32:15 +02:00
7739f324f3 Revert "fix: cicd dependency between workflow (#12)"
This reverts commit 1a25992f03.
2024-08-11 12:32:15 +02:00
a7e50bcec9 Revert "fix: cicd workflow interdependency (#13)"
This reverts commit e0412f87b6.
2024-08-11 12:32:15 +02:00
26 changed files with 838 additions and 2166 deletions

View File

@@ -4,14 +4,6 @@ on:
push: push:
branches: branches:
- main - main
paths:
- "go.mod"
- "go.sum"
- "**/*.go"
- "config.example.yaml"
- "Dockerfile"
- "Makefile"
workflow_dispatch:
env: env:
DOCKER_REGISTRY: gitea.t000-n.de DOCKER_REGISTRY: gitea.t000-n.de
@@ -19,19 +11,17 @@ env:
jobs: jobs:
test: test:
name: test name: test
runs-on: runs-on: ubuntu-latest
- ubuntu-latest
- linux_amd64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 uses: actions/checkout@v4
- name: Setup go - name: Setup go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Create cache key - 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 id: hash-go
with: with:
patterns: | patterns: |
@@ -39,7 +29,7 @@ jobs:
go.sum go.sum
- name: cache go - name: cache go
id: cache-go id: cache-go
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 uses: actions/cache@v4
with: with:
path: | path: |
/go_path /go_path
@@ -54,90 +44,41 @@ jobs:
build_and_push: build_and_push:
name: Build and push name: Build and push
strategy:
matrix:
arch: [amd64, arm64]
needs: needs:
- test - test
runs-on: runs-on: ubuntu-latest
- ubuntu-latest
- linux_${{ matrix.arch }}
steps: steps:
- name: Checkout - 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@v2
- name: Login to Registry - name: Login to Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: docker/login-action@v2
with: with:
registry: ${{ env.DOCKER_REGISTRY }} registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Get Metadata - name: Get Metadata
id: meta id: meta
run: | run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT 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 echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/${{ matrix.arch }} platforms: |
linux/amd64
linux/arm64
push: true push: true
provenance: false
build-args: GOARCH=${{ matrix.arch }}
tags: | tags: |
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-${{ matrix.arch }} ${{ 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
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 }}

View File

@@ -10,19 +10,17 @@ env:
jobs: jobs:
test: test:
name: test name: test
runs-on: runs-on: ubuntu-latest
- ubuntu-latest
- linux_amd64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 uses: actions/checkout@v4
- name: Setup go - name: Setup go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Create cache key - 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 id: hash-go
with: with:
patterns: | patterns: |
@@ -30,7 +28,7 @@ jobs:
go.sum go.sum
- name: cache go - name: cache go
id: cache-go id: cache-go
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 uses: actions/cache@v4
with: with:
path: | path: |
/go_path /go_path
@@ -42,12 +40,3 @@ jobs:
run: make build run: make build
- name: test - name: test
run: make 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

3
.gitignore vendored
View File

@@ -22,6 +22,3 @@
go.work go.work
.vscode .vscode
config.yaml
lcov.info
realdnydns

View File

@@ -1,14 +1,21 @@
FROM golang:1.25-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 as build FROM golang:1.21-alpine
ARG GOARCH=amd64
# Set the Current Working Directory inside the container
WORKDIR /app 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 go mod and sum files
COPY --from=build /app/main / COPY go.mod go.sum ./
CMD ["/main"]
# 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:
test-unit:
go test ./pkg/... -coverprofile=coverage.out go test ./pkg/... -coverprofile=coverage.out
test-race:
go test ./pkg/... -race
test-coverage:
go tool gcov2lcov -infile coverage.out > lcov.info
build: build:
go build go build
lint: lint:
go tool golint ./... 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

View File

@@ -1,12 +1,10 @@
# realDynDNS # realDynDNS
RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries. 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. 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. A service that provides your current external IP is also required.
## Configuration ## Configuration
The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains. 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. Configuration of the IP provider and the DNS provider is mandatory.
@@ -37,52 +35,38 @@ domains:
- "@" - "@"
- www - www
check_interval: 0 0 0/6 * * * * 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 ## DNS Providers
The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported: The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported:
### IONOS ### IONOS
IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted). IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted).
```yaml ```yaml
api_key: <your-api-key> api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns base_url: https://api.hosting.ionos.com/dns
``` ```
## External IP Providers ## External IP Providers
The external IP provider is used to get your current external IP. Currently the following providers are supported: The external IP provider is used to get your current external IP. Currently the following providers are supported:
### Plain ### Plain
Any provider that returns your IP as plain text can be used. The following configuration is required: Any provider that returns your IP as plain text can be used. The following configuration is required:
```yaml ```yaml
url: <your-providers-URL> url: <your-providers-URL>
``` ```
Examples for providers are: Examples for providers are:
- https://ifconfig.me - https://ifconfig.me
- https://api.ipify.org - https://api.ipify.org
## Notification Providers ## Notification Providers
The notification provider is used to send notifications whena IP address changes and a DNS record is updated. The notification provider is used to send notifications whena IP address changes and a DNS record is updated.
### Console ### Console
The console notification provider is used to print the notification to the console. This is the default notification provider. The console notification provider is used to print the notification to the console. This is the default notification provider.
### Gotify ### Gotify
The Gotify notification provider is used to send notifications to a Gotify server. The Gotify notification provider is used to send notifications to a Gotify server.
```yaml ```yaml
@@ -92,19 +76,3 @@ priority: 0
``` ```
The priority must be between 0 and 4. 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

@@ -2,16 +2,12 @@
ip_provider: ip_provider:
type: plain type: plain
config: config:
url: https://api.ipify.org url: https://ifconfig.me
dns_provider: dns_provider:
type: ionos type: ionos
config: config:
api_key: <your-api-key> api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns 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: notification_provider:
type: gotify type: gotify
config: config:
@@ -24,5 +20,3 @@ domains:
- "@" - "@"
- www - www
check_interval: 0 0 0/6 * * * * check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info

19
go.mod
View File

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

41
go.sum
View File

@@ -1,74 +1,47 @@
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron v1.31.2 h1:tAUW64bxYc5QlzEy2t30TnHX2+uInNDajKXxWi4SACA=
github.com/go-co-op/gocron v1.31.2/go.mod h1:39f6KNSGVOU1LO/ZOoZfcSxwlsJDQOKSu8erN0SH48Y=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= 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/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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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.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/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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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.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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.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.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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
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.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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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 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-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/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

86
main.go
View File

@@ -2,10 +2,9 @@ package main
import ( import (
"fmt" "fmt"
"log/slog" "time"
"os"
"strings"
"realdnydns/pkg/changeDetector"
"realdnydns/pkg/config" "realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider" "realdnydns/pkg/dnsProvider"
ionos "realdnydns/pkg/dnsProvider/ionos" ionos "realdnydns/pkg/dnsProvider/ionos"
@@ -14,88 +13,50 @@ import (
"realdnydns/pkg/notificationProvider" "realdnydns/pkg/notificationProvider"
notificationProviderConsole "realdnydns/pkg/notificationProvider/console" notificationProviderConsole "realdnydns/pkg/notificationProvider/console"
gotify "realdnydns/pkg/notificationProvider/gotify" gotify "realdnydns/pkg/notificationProvider/gotify"
"realdnydns/pkg/realDynDns"
"github.com/go-co-op/gocron"
) )
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
configClient := config.Config{} configClient := config.Config{}
err := configClient.Load("config.yaml") err := configClient.Load("config.yaml")
if err != nil { if err != nil {
logger.Error("Failed to load config file", slog.String("error", err.Error()))
panic(err) 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 var externalIpProvider externalIpProvider.ExternalIpProvider
switch configClient.ExternalIPProvider.Type { switch configClient.ExternalIPProvider.Type {
case "plain": case "plain":
logger.Info("Using plain external IP provider", slog.String("external_ip_provider", "plain"))
var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig
err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig) err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig)
if err != nil { if err != nil {
logger.Error("Failed to create config",
slog.String("external_ip_provider", "plain"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
externalIpProvider, err = plainExternalIpProvider.New(plainConfig) 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: 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)) panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type))
} }
var dnsProvider dnsProvider.DNSProvider var dnsProvider dnsProvider.DNSProvider
switch configClient.DNSProvider.Type { switch configClient.DNSProvider.Type {
case "ionos": case "ionos":
logger.Info("Using IONOS DNS provider", slog.String("dns_provider", "ionos"))
var ionosConfig ionos.IONOSConfig var ionosConfig ionos.IONOSConfig
err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig) err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig)
if err != nil { if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
dnsProvider, err = ionos.NewIonos(&ionosConfig) dnsProvider, err = ionos.NewIonos(&ionosConfig)
if err != nil { if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
default: default:
logger.Error("Unknown DNS provider", slog.String("dns_provider", configClient.DNSProvider.Type))
panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type)) panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type))
} }
var notificationProvider notificationProvider.NotificationProvider var notificationProvider notificationProvider.NotificationProvider
switch configClient.NotificationProvider.Type { switch configClient.NotificationProvider.Type {
case "gotify": case "gotify":
logger.Info("Using Gotify notification provider", slog.String("notification_provider", "gotify"))
var gotifyConfig gotify.NotificationProviderImplGotifyConfig var gotifyConfig gotify.NotificationProviderImplGotifyConfig
err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig) err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig)
if err != nil { if err != nil {
@@ -104,39 +65,30 @@ func main() {
notificationProvider, err = gotify.New(gotifyConfig) notificationProvider, err = gotify.New(gotifyConfig)
if err != nil { if err != nil {
logger.Error("Failed to create Gotify notification provider",
slog.String("notification_provider", "gotify"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
default: default:
logger.Info("Using console notification provider", slog.String("notification_provider", "console")) // Use default console notification provider
notificationProvider = notificationProviderConsole.New() notificationProvider = notificationProviderConsole.New()
} }
rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains, logger.With(slog.String("service", "realDynDns"))) changeDetector := changeDetector.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains)
switch configClient.Mode { s := gocron.NewScheduler(time.UTC)
case config.ScheduledMode: s.SingletonMode()
logger.Info("Running in scheduled mode", slog.String("interval", configClient.CheckInterval)) job, err := s.CronWithSeconds(configClient.CheckInterval).DoWithJobDetails(func(job gocron.Job) {
numberChanged, err := changeDetector.DetectAndApplyChanges()
schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval) if err != nil {
panic(err)
}
fmt.Printf("Number of changes: %d\n", numberChanged)
fmt.Println("Next run:", job.NextRun())
})
if err != nil { if err != nil {
logger.Error("Failed to create scheduler", slog.String("error", err.Error()))
panic(err) panic(err)
} }
logger.Info("Next run:", slog.String("time", job.NextRun().String())) fmt.Println("Starting scheduler")
schedule.StartBlocking() fmt.Println("Next run:", job.NextRun())
case config.RunOnceMode: s.StartBlocking()
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)
}
}
} }

View File

@@ -0,0 +1,66 @@
package changeDetector
import (
"fmt"
"realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider"
"realdnydns/pkg/externalIpProvider"
"realdnydns/pkg/notificationProvider"
)
type ChangeDetector struct {
externalIpProvider externalIpProvider.ExternalIpProvider
dnsProvider dnsProvider.DNSProvider
notificationProvider notificationProvider.NotificationProvider
domains []config.DomainConfig
}
func New(
externalIpProvider externalIpProvider.ExternalIpProvider,
dnsProvider dnsProvider.DNSProvider,
notificationProvider notificationProvider.NotificationProvider,
domains []config.DomainConfig,
) ChangeDetector {
return ChangeDetector{
externalIpProvider: externalIpProvider,
dnsProvider: dnsProvider,
notificationProvider: notificationProvider,
domains: domains,
}
}
func (c *ChangeDetector) DetectAndApplyChanges() (int, error) {
externalIp, err := c.externalIpProvider.GetExternalIp()
if err != nil {
return 0, err
}
var numberUpdated int
for _, domain := range c.domains {
for _, subdomain := range domain.Subdomains {
currentRecord, err := c.dnsProvider.GetRecord(domain.TLD, subdomain)
if err != nil {
return numberUpdated, err
}
if currentRecord.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 {
return numberUpdated, err
}
_, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled)
numberUpdated++
if err != nil {
return numberUpdated, err
}
}
}
}
return numberUpdated, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,22 @@
package config package config
import ( import (
"errors"
"fmt"
"log/slog"
"os" "os"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type DomainConfig struct {
TLD string `yaml:"tld"`
Subdomains []string `yaml:"subdomains"`
}
type Config struct { type Config struct {
Mode string `yaml:"mode"`
ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"` ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"`
DNSProvider DNSProviderConfig `yaml:"dns_provider"` DNSProvider DNSProviderConfig `yaml:"dns_provider"`
NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"` NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
Domains []DomainConfig `yaml:"domains"` Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"` 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 ExternalIpProviderConfig struct {
@@ -58,34 +35,15 @@ type NotificationProviderConfig struct {
} }
func (c *Config) Load(filePath string) error { func (c *Config) Load(filePath string) error {
err := yaml.Unmarshal([]byte(filePath), c)
if err != nil {
inputConfig, err := os.ReadFile(filePath) inputConfig, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read config file: %w", err) return err
} }
if err := yaml.Unmarshal(inputConfig, c); err != nil { return yaml.Unmarshal(inputConfig, c)
return fmt.Errorf("failed to unmarshal config file: %w", err)
} }
if err := c.validate(); err != nil { return err
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
} }

View File

@@ -1,52 +1,69 @@
package config package config
import ( import (
"fmt"
"testing" "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) { func TestLoad(t *testing.T) {
t.Run("Can find file", testLoadCanFindFile()) t.Run("Can find file", testLoadCanFindFile())
t.Run("Cannot find file", testFactoryFileRelatedError( t.Run("Cannot find file", testLoadCannotFindFile())
"nonexistent.yaml", t.Run("Unmarshals from direct input", testLoadUnmarshalsFromDirectInput())
"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) { func testLoadCanFindFile() func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
c := Config{} c := Config{}
err := c.Load("./__mocks__/testLoadCanFindFile.yaml") err := c.Load("./__mocks__/testLoadCanFindFile.yaml")
want := err == nil && c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.Mode == "RunOnce" want := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain"
if !want || err != nil { if !want || err != nil {
t.Fatalf("Failed to load config file, expected no errors but got: %s", err) t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want)
}
}
}
func testLoadCannotFindFile() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load("nonexistent.yaml")
want := err != nil
if !want {
t.Fatalf("Config didn't throw an error")
}
}
}
func testLoadUnmarshalsFromDirectInput() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load(`---
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: exampleAPIKey
base_url: https://example.com
notification_provider:
type: gotify
config:
url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *`)
want := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.NotificationProvider.Type == "gotify"
if !want || err != nil {
t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want)
} }
} }
} }

View File

@@ -0,0 +1,213 @@
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)
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 != "" {
domain = subdomain + "." + tld
} else {
domain = 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
}
if res.StatusCode != 200 {
return nil, errors.New("error updating record")
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
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 package ionosDnsProvider
import ( import (
"context"
"errors" "errors"
"net" "net"
"net/http"
"realdnydns/model/common" "realdnydns/model/common"
"realdnydns/pkg/dnsProvider" "realdnydns/pkg/dnsProvider"
"sync" ionosAPI "realdnydns/pkg/dnsProvider/ionos/api"
ionosDnsClient "gitea.t000-n.de/t.behrendt/ionosDnsClient"
) )
type IONOS struct { type IONOS struct {
client *ionosDnsClient.ClientWithResponses API ionosAPI.IonosAPI
zoneIdMap map[string]string
defaultTtl int
defaultPrio int
zoneIdMapMutex sync.Mutex
} }
type IONOSConfig struct { type IONOSConfig struct {
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
DefaultTTL *int `yaml:"default_ttl,omitempty"`
DefaultPrio *int `yaml:"default_prio,omitempty"`
} }
func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) { 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") 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{ return &IONOS{
client: client, ionosAPI.New(config.APIKey, config.BaseURL),
zoneIdMap: make(map[string]string),
defaultTtl: defaultTtl,
defaultPrio: defaultPrio,
}, nil }, nil
} }
func (i *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { 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)
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 (i *IONOS) getZoneIdForTld(tld string) (string, error) { func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
i.zoneIdMapMutex.Lock() return ionos.API.GetARecord(tld, subdomain)
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
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -64,10 +64,8 @@ func (p *NotificationProviderImplGotify) SendNotification(title string, message
} }
messageUrl := p.Url messageUrl := p.Url
messageUrl = *messageUrl.JoinPath("message") messageUrl.JoinPath("message")
queryParams := messageUrl.Query() messageUrl.Query().Add("token", p.Token)
queryParams.Add("token", p.Token)
messageUrl.RawQuery = queryParams.Encode()
req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson)) req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson))
if err != nil { if err != nil {

View File

@@ -1,152 +0,0 @@
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

@@ -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"
]
}
]
}