21 Commits

Author SHA1 Message Date
06028eb5d6 ci: only deploy on relevant code changes
All checks were successful
CI / test (pull_request) Successful in 1m19s
2025-04-13 18:36:45 +02:00
050d309957 Add renovate.json
All checks were successful
CI / test (pull_request) Successful in 3m10s
2025-04-13 16:32:42 +00:00
ad20ad46b0 chore: slimmer docker base image & binary (#29)
All checks were successful
CD / test (push) Successful in 2m26s
CD / Build and push (amd64) (push) Successful in 1m37s
CD / Build and push (arm64) (push) Successful in 3m54s
CD / Create manifest (push) Successful in 11s
Reduction of 352 MiB -> 8.22 MiB

Reviewed-on: #29
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2025-02-16 20:27:29 +01:00
ccf4f5dbbb ci: refactor CD to only publish a single OCI manifest for all arches (#28)
All checks were successful
CD / test (push) Successful in 2m21s
CD / Build and push (amd64) (push) Successful in 53s
CD / Build and push (arm64) (push) Successful in 2m11s
CD / Create manifest (push) Successful in 11s
Reviewed-on: #28
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2025-01-18 22:28:30 +01:00
e53827adf0 ci: refactor multi arch build (#27)
All checks were successful
CD / test (push) Successful in 1m34s
CD / Build and push (amd64) (push) Successful in 57s
CD / Build and push (arm64) (push) Successful in 2m19s
Reviewed-on: #27
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2025-01-07 19:08:58 +01:00
ad0932f4aa docs: log level (#23)
All checks were successful
CD / test (push) Successful in 29s
CD / Build and push (push) Successful in 4m16s
Reviewed-on: #23
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2025-01-01 12:25:14 +01:00
fff36bf807 feat: concurrently check and update all domains (#24)
All checks were successful
CD / test (push) Successful in 2m32s
CD / Build and push (push) Successful in 2m53s
Reviewed-on: #24
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-30 17:37:49 +01:00
1c725993f5 fix: increase resilliency (#22)
All checks were successful
CD / test (push) Successful in 47s
CD / Build and push (push) Successful in 3m2s
Reviewed-on: #22
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 20:44:19 +01:00
3ad5b1ec0e feat: logging (#20)
All checks were successful
CD / test (push) Successful in 52s
CD / Build and push (push) Successful in 15m10s
Reviewed-on: #20
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 19:52:21 +01:00
1ea43ac4cf chore: upgrade from go 1.20 to 1.23 (#21)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 3m1s
Reviewed-on: #21
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 17:09:34 +01:00
b781399b47 fix: only query relevant records when looking up record ids on Ionos API (#19)
All checks were successful
CD / test (push) Successful in 5m33s
CD / Build and push (push) Successful in 4m7s
Reviewed-on: #19
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 16:55:26 +01:00
40a41ce7ca fix: align go version of modfile with dockerfile (#18)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 2m47s
Reviewed-on: #18
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-27 12:32:51 +01:00
ef57421268 ci: check format in CI pipeline (#17)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 2m41s
Reviewed-on: #17
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 19:37:47 +01:00
a01c328ac5 docs: mode feature (#16)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 3m44s
Reviewed-on: #16
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 14:59:51 +01:00
ac786f533d feat: add mode selecting (#15)
All checks were successful
CD / test (push) Successful in 44s
CD / Build and push (push) Successful in 3m5s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-12-23 14:17:46 +01:00
e84a409d82 chore: cleanup (#9)
All checks were successful
CD / test (push) Successful in 40s
CD / Build and push (push) Successful in 2m28s
2024-08-11 13:08:06 +02:00
637a789897 fix: revert cicd changes (#14)
All checks were successful
CD / test (push) Successful in 20s
CD / Build and push (push) Successful in 4m4s
2024-08-11 12:36:33 +02:00
a96418fb3d docs: document config directory (#10)
All checks were successful
CI / test (push) Successful in 1m39s
2024-08-11 12:31:19 +02:00
e0412f87b6 fix: cicd workflow interdependency (#13)
All checks were successful
CI / test (push) Successful in 1m38s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 12:28:30 +02:00
1a25992f03 fix: cicd dependency between workflow (#12)
All checks were successful
CI / test (push) Successful in 38s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 12:14:38 +02:00
62a05d5e1e refactor: cicd cd workflow depends on ci workflow success (#11) 2024-08-11 12:03:10 +02:00
24 changed files with 512 additions and 237 deletions

View File

@@ -1,16 +1,35 @@
name: CD name: CD
on: on:
workflow_run: push:
workflows: ["CI"] branches:
branches: [main] - 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: test:
name: test name: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -46,18 +65,58 @@ jobs:
build_and_push: build_and_push:
name: Build and push name: Build and push
requires: strategy:
matrix:
arch: [amd64, arm64]
needs:
- test - test
- check-changes
if: ${{ needs.check-changes.outputs.code == 'true' }}
runs-on:
- ubuntu-latest
- linux_${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Get Metadata
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.arch }}
push: true
provenance: false
build-args: GOARCH=${{ matrix.arch }}
tags: |
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-${{ matrix.arch }}
create_manifest:
name: Create manifest
needs:
- build_and_push
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Get Metadata
uses: docker/setup-qemu-action@v2 id: meta
run: |
- name: Set up Docker Buildx echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
uses: docker/setup-buildx-action@v2 echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Login to Registry - name: Login to Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
@@ -66,21 +125,10 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Get Metadata - name: Create manifest
id: meta
run: | run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT docker manifest create ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:latest \
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT ${{ 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
- name: Build and push docker manifest push ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:latest
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:latest

View File

@@ -40,3 +40,5 @@ 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,3 +22,4 @@
go.work go.work
.vscode .vscode
config.yaml

View File

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

View File

@@ -6,3 +6,18 @@ 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,10 +1,12 @@
# 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.
@@ -35,38 +37,52 @@ 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
@@ -76,3 +92,19 @@ 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://ifconfig.me url: https://api.ipify.org
dns_provider: dns_provider:
type: ionos type: ionos
config: config:
@@ -20,3 +20,5 @@ 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.20 go 1.23
require ( require (
github.com/go-co-op/gocron v1.37.0 github.com/go-co-op/gocron v1.37.0
@@ -8,12 +8,8 @@ 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,8 +2,6 @@ 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=
@@ -11,9 +9,11 @@ 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,16 +32,14 @@ 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,9 +2,10 @@ package main
import ( import (
"fmt" "fmt"
"time" "log/slog"
"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"
@@ -13,50 +14,88 @@ 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 {
@@ -65,30 +104,39 @@ 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:
// Use default console notification provider logger.Info("Using console notification provider", slog.String("notification_provider", "console"))
notificationProvider = notificationProviderConsole.New() notificationProvider = notificationProviderConsole.New()
} }
changeDetector := changeDetector.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains) rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains, logger.With(slog.String("service", "realDynDns")))
s := gocron.NewScheduler(time.UTC) switch configClient.Mode {
s.SingletonMode() case config.ScheduledMode:
job, err := s.CronWithSeconds(configClient.CheckInterval).DoWithJobDetails(func(job gocron.Job) { logger.Info("Running in scheduled mode", slog.String("interval", configClient.CheckInterval))
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) 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") logger.Info("Next run:", slog.String("time", job.NextRun().String()))
fmt.Println("Next run:", job.NextRun()) schedule.StartBlocking()
s.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)
}
}
} }

View File

@@ -1,66 +0,0 @@
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,16 +1,15 @@
---
ip_provider: ip_provider:
type: plain type: plain
config: config:
url: https://ifconfig.me url: https://example.com
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:
- "@" - "@"
- www check_interval: 0 0 * * * *
check_interval: 0 0 0/6 * * * * mode: RunOnce

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,69 +1,52 @@
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", testLoadCannotFindFile()) t.Run("Cannot find file", testFactoryFileRelatedError(
t.Run("Unmarshals from direct input", testLoadUnmarshalsFromDirectInput()) "nonexistent.yaml",
"failed to read config file: open ./__mocks__/nonexistent.yaml: no such file or directory",
))
t.Run("Missing CheckInterval in Scheduled mode", testFactoryFileRelatedError(
"testLoadMissingCheckInterval.yaml",
"failed to validate config: check interval must be set when mode is 'Scheduled'",
))
t.Run("Invalid mode", testFactoryFileRelatedError(
"testLoadInvalidMode.yaml",
"failed to validate config: mode must be one of 'RunOnce' or 'Scheduled'",
))
t.Run("Invalid YAML", testFactoryFileRelatedError(
"testLoadInvalidYAML.yaml",
"failed to unmarshal config file: yaml: line 2: did not find expected key",
))
} }
func testLoadCanFindFile() func(t *testing.T) { 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 := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" want := err == nil && c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.Mode == "RunOnce"
if !want || err != nil { if !want || err != nil {
t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want) t.Fatalf("Failed to load config file, expected no errors but got: %s", err)
}
}
}
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,6 +7,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"realdnydns/model/common" "realdnydns/model/common"
) )
@@ -18,7 +19,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) (*http.Response, error) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error)
} }
type IonosAPIImpl struct { type IonosAPIImpl struct {
@@ -76,19 +77,29 @@ func New(APIKey string, BaseURL string) IonosAPI {
} }
} }
func (i *IonosAPIImpl) HttpCall(method string, url string, body io.Reader) (*http.Response, error) { func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
req, err := http.NewRequest(method, i.BaseURL+url, body) requestUrl, _ := url.Parse(i.BaseURL + path)
query := requestUrl.Query()
for key, value := range queryParams {
query.Add(key, value)
}
requestUrl.RawQuery = query.Encode()
req, err := http.NewRequest(method, requestUrl.String(), body)
if err != nil { 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) res, err := i.HttpCall("GET", "/v1/zones", nil, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -109,7 +120,14 @@ 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) {
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil) var domain string
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -120,13 +138,6 @@ 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
@@ -157,18 +168,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)) res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil)
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)
@@ -192,7 +203,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) res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -76,7 +76,8 @@ 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" { } else if r.RequestURI == "/v1/zones/1234567890?recordName=example.com&recordType=A" ||
r.RequestURI == "/v1/zones/1234567890?recordName=sub.example.com&recordType=A" {
response = zoneResponseJson response = zoneResponseJson
} else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" { } else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" {
response = recordResponseSubJson response = recordResponseSubJson
@@ -106,7 +107,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) res, err := api.HttpCall("GET", "/v1/zones", nil, nil)
if err != nil { if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err) t.Fatalf("HttpCall() returned unexpected error: %v", err)
@@ -120,7 +121,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) res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil, nil)
if err != nil { if err != nil {
t.Fatalf("HttpCall() returned unexpected error: %v", err) t.Fatalf("HttpCall() returned unexpected error: %v", err)
@@ -141,7 +142,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) res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil, 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) (*http.Response, error) { func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
return m.HttpCallFunc(method, url, body) return m.HttpCallFunc(method, url, body)
} }

View File

@@ -64,8 +64,10 @@ func (p *NotificationProviderImplGotify) SendNotification(title string, message
} }
messageUrl := p.Url messageUrl := p.Url
messageUrl.JoinPath("message") messageUrl = *messageUrl.JoinPath("message")
messageUrl.Query().Add("token", p.Token) queryParams := messageUrl.Query()
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

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

View File

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

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}