2 Commits

Author SHA1 Message Date
c58a236606 style: format
All checks were successful
CI / test (pull_request) Successful in 1m38s
2024-08-11 12:23:11 +02:00
36142c0a17 fix: cicd workflow interdependency 2024-08-11 12:23:07 +02:00
24 changed files with 235 additions and 540 deletions

View File

@@ -1,134 +1,52 @@
name: CD name: CD
on: on:
push: workflow_run:
branches: workflows: ["ci.yaml"]
- main branches: [main]
types:
- completed
env: env:
DOCKER_REGISTRY: gitea.t000-n.de DOCKER_REGISTRY: gitea.t000-n.de
jobs: jobs:
check-changes:
name: Check changes
runs-on: ubuntu-latest
outputs:
changes: ${{ steps.filter.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get changes
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
code:
- 'go.mod'
- 'go.sum'
- '**/*.go'
- 'config.example.yaml'
- 'Dockerfile'
- 'Makefile'
test:
name: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Create cache key
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go
with:
patterns: |
go.mod
go.sum
- name: cache go
id: cache-go
uses: actions/cache@v4
with:
path: |
/go_path
/go_cache
key: go_path-${{ steps.hash-go.outputs.hash }}
restore-keys: |-
go_cache-${{ steps.hash-go.outputs.hash }}
- name: build
run: make build
- name: test
run: make test
build_and_push: build_and_push:
name: Build and push name: Build and push
strategy: runs-on: ubuntu-latest
matrix: if: ${{ github.event.workflow_run.conclusion == 'success' }}
arch: [amd64, arm64]
needs:
- test
- check-changes
if: ${{ needs.check-changes.outputs.code == 'true' }}
runs-on:
- ubuntu-latest
- linux_${{ matrix.arch }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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@v2 uses: docker/setup-buildx-action@v2
- name: Login to Registry - name: Login to Registry
uses: docker/login-action@v2 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@v4 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_manifest:
name: Create manifest
needs:
- build_and_push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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@v2
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 }}:latest \
${{ 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 }}:latest

View File

@@ -1,6 +1,10 @@
name: CI name: CI
on: on:
pull_request: pull_request:
push:
branches:
- main
env: env:
GOPATH: /go_path GOPATH: /go_path
@@ -40,5 +44,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

1
.gitignore vendored
View File

@@ -22,4 +22,3 @@
go.work go.work
.vscode .vscode
config.yaml

View File

@@ -1,14 +1,21 @@
FROM golang:1.23-alpine 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 # 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

@@ -6,18 +6,3 @@ build:
lint: lint:
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,7 +2,7 @@
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:
@@ -20,5 +20,3 @@ domains:
- "@" - "@"
- www - www
check_interval: 0 0 0/6 * * * * check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info

6
go.mod
View File

@@ -1,6 +1,6 @@
module realdnydns module realdnydns
go 1.23 go 1.20
require ( require (
github.com/go-co-op/gocron v1.37.0 github.com/go-co-op/gocron v1.37.0
@@ -8,8 +8,12 @@ require (
) )
require ( require (
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/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4 // 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
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

10
go.sum
View File

@@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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=
@@ -9,11 +11,9 @@ 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/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/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=
@@ -21,10 +21,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
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/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/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=
@@ -32,14 +32,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
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 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=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
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=
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 { if err != nil {
logger.Error("Failed to create scheduler", slog.String("error", err.Error()))
panic(err)
}
logger.Info("Next run:", slog.String("time", job.NextRun().String()))
schedule.StartBlocking()
case config.RunOnceMode:
logger.Info("Running in run once mode")
_, err := rdd.RunOnce()
if err != nil {
logger.Error("Failed to run once", slog.String("error", err.Error()))
panic(err) panic(err)
} }
fmt.Printf("Number of changes: %d\n", numberChanged)
fmt.Println("Next run:", job.NextRun())
})
if err != nil {
panic(err)
} }
fmt.Println("Starting scheduler")
fmt.Println("Next run:", job.NextRun())
s.StartBlocking()
} }

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 {
inputConfig, err := os.ReadFile(filePath) err := yaml.Unmarshal([]byte(filePath), c)
if err != nil { if err != nil {
return fmt.Errorf("failed to read config file: %w", err) inputConfig, err := os.ReadFile(filePath)
if err != nil {
return err
}
return yaml.Unmarshal(inputConfig, c)
} }
if err := yaml.Unmarshal(inputConfig, c); err != nil { return err
return fmt.Errorf("failed to unmarshal config file: %w", err)
}
if err := c.validate(); err != nil {
return fmt.Errorf("failed to validate config: %w", err)
}
return nil
}
func (c *Config) validate() error {
if c.Mode != RunOnceMode && c.Mode != ScheduledMode {
return errors.New("mode must be one of 'RunOnce' or 'Scheduled'")
}
if c.Mode == ScheduledMode && c.CheckInterval == "" {
return errors.New("check interval must be set when mode is 'Scheduled'")
}
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
return fmt.Errorf("log level must be one of 'debug', 'info', 'warn', 'error', but got %s", c.LogLevel)
}
return nil
} }

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

@@ -7,7 +7,6 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"realdnydns/model/common" "realdnydns/model/common"
) )
@@ -19,7 +18,7 @@ type IonosAPI interface {
SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*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) GetZoneId(tld string) (string, error)
GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error)
HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) HttpCall(method string, url string, body io.Reader) (*http.Response, error)
} }
type IonosAPIImpl struct { type IonosAPIImpl struct {
@@ -77,29 +76,19 @@ func New(APIKey string, BaseURL string) IonosAPI {
} }
} }
func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) { func (i *IonosAPIImpl) HttpCall(method string, url string, body io.Reader) (*http.Response, error) {
requestUrl, _ := url.Parse(i.BaseURL + path) req, err := http.NewRequest(method, i.BaseURL+url, body)
query := requestUrl.Query()
for key, value := range queryParams {
query.Add(key, value)
}
requestUrl.RawQuery = query.Encode()
req, err := http.NewRequest(method, requestUrl.String(), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Add("X-API-Key", i.APIKey) 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) return i.HTTPClient.Do(req)
} }
func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) { func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
res, err := i.HttpCall("GET", "/v1/zones", nil, nil) res, err := i.HttpCall("GET", "/v1/zones", nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -120,14 +109,7 @@ func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
} }
func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
var domain string res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil)
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -138,6 +120,13 @@ func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string,
zone := ZoneResponse{} zone := ZoneResponse{}
json.Unmarshal(responseBody, &zone) json.Unmarshal(responseBody, &zone)
var domain string
if subdomain != "" {
domain = subdomain + "." + tld
} else {
domain = tld
}
for _, record := range zone.Records { for _, record := range zone.Records {
if record.Type == recordType && record.Name == domain { if record.Type == recordType && record.Name == domain {
return record.Id, nil return record.Id, nil
@@ -168,18 +157,18 @@ func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl i
return nil, err return nil, err
} }
res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil) res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest))
if err != nil { if err != nil {
return nil, err return nil, err
} }
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
if res.StatusCode != 200 { if res.StatusCode != 200 {
return nil, errors.New("error updating record") return nil, errors.New("error updating record")
} }
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
changeRecord := ChangeRecord{} changeRecord := ChangeRecord{}
json.Unmarshal(responseBody, &changeRecord) json.Unmarshal(responseBody, &changeRecord)
@@ -203,7 +192,7 @@ func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARe
return nil, err return nil, err
} }
res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil) res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -76,8 +76,7 @@ func utilMockServerImpl() func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" { if r.Method == "GET" {
if r.RequestURI == "/v1/zones" { if r.RequestURI == "/v1/zones" {
response = zonesResponseJson response = zonesResponseJson
} else if r.RequestURI == "/v1/zones/1234567890?recordName=example.com&recordType=A" || } else if r.RequestURI == "/v1/zones/1234567890" {
r.RequestURI == "/v1/zones/1234567890?recordName=sub.example.com&recordType=A" {
response = zoneResponseJson response = zoneResponseJson
} else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" { } else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" {
response = recordResponseSubJson response = recordResponseSubJson
@@ -107,7 +106,7 @@ func TestHttpCall(t *testing.T) {
func testHttpCallGet(api IonosAPI) func(t *testing.T) { func testHttpCallGet(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
res, err := api.HttpCall("GET", "/v1/zones", nil, nil) res, err := api.HttpCall("GET", "/v1/zones", nil)
if err != nil { if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err) t.Fatalf("HttpCall() returned unexpected error: %v", err)
@@ -121,7 +120,7 @@ func testHttpCallGet(api IonosAPI) func(t *testing.T) {
func testHttpCallPut(api IonosAPI) func(t *testing.T) { func testHttpCallPut(api IonosAPI) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil, nil) res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil)
if err != nil { if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err) t.Fatalf("HttpCall() returned unexpected error: %v", err)
@@ -142,7 +141,7 @@ func testHttpCallNonExistingEndpoint() func(t *testing.T) {
api := New("dummyKey", mockServer.URL) api := New("dummyKey", mockServer.URL)
res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil, nil) res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil)
if err != nil { if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err) t.Fatalf("HttpCall() returned unexpected error: %v", err)

View File

@@ -82,7 +82,7 @@ func (m *MockIonosAPI) GetZoneId(tld string) (string, error) {
return m.GetZoneIdFunc(tld) return m.GetZoneIdFunc(tld)
} }
func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) { func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader) (*http.Response, error) {
return m.HttpCallFunc(method, url, body) return m.HttpCallFunc(method, url, body)
} }

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,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}