Compare commits
49 Commits
90369bf147
...
0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| b5bc615cbb | |||
| 0193eac6e5 | |||
| dc76c5fb26 | |||
| eb182ac7ce | |||
| ae6981cb02 | |||
| 6511147a41 | |||
| 2f05e48962 | |||
| a79ce64e82 | |||
| 660f2eac0d | |||
| 0a722ff1b2 | |||
| 5875af55bb | |||
| bff59f36a8 | |||
| 73615c65ee | |||
| 531b5baecd | |||
| 629765985b | |||
| 99e1214a83 | |||
| 29c62a8b1f | |||
| 0fdd151e6d | |||
| 89d965a4d2 | |||
| d4e48c2fbf | |||
| a34f1f0a9a | |||
| ab150a88ef | |||
| f1e863b098 | |||
| ad20ad46b0 | |||
| ccf4f5dbbb | |||
| e53827adf0 | |||
| ad0932f4aa | |||
| fff36bf807 | |||
| 1c725993f5 | |||
| 3ad5b1ec0e | |||
| 1ea43ac4cf | |||
| b781399b47 | |||
| 40a41ce7ca | |||
| ef57421268 | |||
| a01c328ac5 | |||
| ac786f533d | |||
| e84a409d82 | |||
| 637a789897 | |||
| a96418fb3d | |||
| e0412f87b6 | |||
| 1a25992f03 | |||
| 62a05d5e1e | |||
| 7bb1e9ca08 | |||
| b8bdcaa35e | |||
| 432974b0d0 | |||
| 380d7eaa4e | |||
| 74c38313a2 | |||
| 31ca7b1065 | |||
| 99361a5ccd |
141
.gitea/workflows/cd.yaml
Normal file
141
.gitea/workflows/cd.yaml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "**/*.go"
|
||||||
|
- "config.example.yaml"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "Makefile"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_REGISTRY: gitea.t000-n.de
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
- name: Setup go
|
||||||
|
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: true
|
||||||
|
- name: Create cache key
|
||||||
|
uses: https://gitea.com/actions/go-hashfiles@264ae76b7e50173ce71ed7da4b48e5e517f3f9ec # v0.0.1
|
||||||
|
id: hash-go
|
||||||
|
with:
|
||||||
|
patterns: |
|
||||||
|
go.mod
|
||||||
|
go.sum
|
||||||
|
- name: cache go
|
||||||
|
id: cache-go
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/go_path
|
||||||
|
/go_cache
|
||||||
|
key: go_path-${{ steps.hash-go.outputs.hash }}
|
||||||
|
restore-keys: |-
|
||||||
|
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||||
|
- name: build
|
||||||
|
run: make build
|
||||||
|
- name: test
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
build_and_push:
|
||||||
|
name: Build and push
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [amd64, arm64]
|
||||||
|
needs:
|
||||||
|
- test
|
||||||
|
runs-on:
|
||||||
|
- ubuntu-latest
|
||||||
|
- linux_${{ matrix.arch }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
- name: Get Metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
|
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_tag:
|
||||||
|
name: Create tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.tag.outputs.new-tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: https://gitea.t000-n.de/t.behrendt/conventional-semantic-git-tag-increment@0.1.20
|
||||||
|
id: tag
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
- run: |
|
||||||
|
git tag ${{ steps.tag.outputs.new-tag }}
|
||||||
|
git push origin ${{ steps.tag.outputs.new-tag }}
|
||||||
|
- name: Set output
|
||||||
|
run: |
|
||||||
|
echo "tag=${{ steps.tag.outputs.new-tag }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
create_manifest:
|
||||||
|
name: Create manifest
|
||||||
|
needs:
|
||||||
|
- build_and_push
|
||||||
|
- create_tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
|
- name: Get Metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Create manifest
|
||||||
|
run: |
|
||||||
|
docker manifest create ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }} \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-arm64
|
||||||
|
|
||||||
|
docker manifest push ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }}
|
||||||
44
.gitea/workflows/ci.yaml
Normal file
44
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GOPATH: /go_path
|
||||||
|
GOCACHE: /go_cache
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
- name: Setup go
|
||||||
|
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: true
|
||||||
|
- name: Create cache key
|
||||||
|
uses: https://gitea.com/actions/go-hashfiles@264ae76b7e50173ce71ed7da4b48e5e517f3f9ec # v0.0.1
|
||||||
|
id: hash-go
|
||||||
|
with:
|
||||||
|
patterns: |
|
||||||
|
go.mod
|
||||||
|
go.sum
|
||||||
|
- name: cache go
|
||||||
|
id: cache-go
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/go_path
|
||||||
|
/go_cache
|
||||||
|
key: go_path-${{ steps.hash-go.outputs.hash }}
|
||||||
|
restore-keys: |-
|
||||||
|
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||||
|
- name: build
|
||||||
|
run: make build
|
||||||
|
- name: test
|
||||||
|
run: make test
|
||||||
|
- name: check:format
|
||||||
|
run: make check-format
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,3 +21,5 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
config.yaml
|
||||||
|
|||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.25-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 as build
|
||||||
|
|
||||||
|
ARG GOARCH=amd64
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o main .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12@sha256:4b2a093ef4649bccd586625090a3c668b254cfe180dee54f4c94f3e9bd7e381e
|
||||||
|
COPY --from=build /app/main /
|
||||||
|
CMD ["/main"]
|
||||||
23
Makefile
Normal file
23
Makefile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
test:
|
||||||
|
go test ./pkg/... -coverprofile=coverage.out
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golint ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
make build
|
||||||
|
./realdyndns
|
||||||
|
|
||||||
|
format:
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
check-format:
|
||||||
|
@OUTPUT=$$(gofmt -l .); \
|
||||||
|
if [ -n "$$OUTPUT" ]; then \
|
||||||
|
echo "Formatter failed for:"; \
|
||||||
|
echo "$$OUTPUT"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
108
README.md
108
README.md
@@ -1,2 +1,110 @@
|
|||||||
# realDynDNS
|
# realDynDNS
|
||||||
|
|
||||||
|
RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries.
|
||||||
|
|
||||||
|
This service requires your DNS provider to expose an API that allows your DNS entries to be changed.
|
||||||
|
A service that provides your current external IP is also required.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains.
|
||||||
|
|
||||||
|
Configuration of the IP provider and the DNS provider is mandatory.
|
||||||
|
|
||||||
|
The `check_interval` is mandatory and follows the cron syntax including seconds. It defines how often the IP is checked and the DNS entries are updated. If you don't own the external ip provider, please be nice and don't set the interval too low.
|
||||||
|
|
||||||
|
You may configure multiple top level domains with multiple subdomains. If you also want the top level domain to be updated, add it to the subdomains list with an `@`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ip_provider:
|
||||||
|
type: plain
|
||||||
|
config:
|
||||||
|
url: https://ifconfig.me
|
||||||
|
dns_provider:
|
||||||
|
type: ionos
|
||||||
|
config:
|
||||||
|
api_key: <your-api-key>
|
||||||
|
base_url: https://api.hosting.ionos.com/dns
|
||||||
|
notification_provider:
|
||||||
|
type: gotify
|
||||||
|
config:
|
||||||
|
url: <your-gotify-host>
|
||||||
|
token: <your-token>
|
||||||
|
priority: 0
|
||||||
|
domains:
|
||||||
|
- tld: example.com
|
||||||
|
subdomains:
|
||||||
|
- "@"
|
||||||
|
- www
|
||||||
|
check_interval: 0 0 0/6 * * * *
|
||||||
|
mode: Scheduled
|
||||||
|
log_level: info
|
||||||
|
```
|
||||||
|
|
||||||
|
The config file is expected to be in the same directory as the binary and called `config.yaml`. For the OCR image, the root directory is `/app`.
|
||||||
|
|
||||||
|
## DNS Providers
|
||||||
|
|
||||||
|
The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported:
|
||||||
|
|
||||||
|
### IONOS
|
||||||
|
|
||||||
|
IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api_key: <your-api-key>
|
||||||
|
base_url: https://api.hosting.ionos.com/dns
|
||||||
|
```
|
||||||
|
|
||||||
|
## External IP Providers
|
||||||
|
|
||||||
|
The external IP provider is used to get your current external IP. Currently the following providers are supported:
|
||||||
|
|
||||||
|
### Plain
|
||||||
|
|
||||||
|
Any provider that returns your IP as plain text can be used. The following configuration is required:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: <your-providers-URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples for providers are:
|
||||||
|
|
||||||
|
- https://ifconfig.me
|
||||||
|
- https://api.ipify.org
|
||||||
|
|
||||||
|
## Notification Providers
|
||||||
|
|
||||||
|
The notification provider is used to send notifications whena IP address changes and a DNS record is updated.
|
||||||
|
|
||||||
|
### Console
|
||||||
|
|
||||||
|
The console notification provider is used to print the notification to the console. This is the default notification provider.
|
||||||
|
|
||||||
|
### Gotify
|
||||||
|
|
||||||
|
The Gotify notification provider is used to send notifications to a Gotify server.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: <your-gotify-host>
|
||||||
|
token: <your-token>
|
||||||
|
priority: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The priority must be between 0 and 4.
|
||||||
|
|
||||||
|
## Mode Selection
|
||||||
|
|
||||||
|
Two modes are available:
|
||||||
|
|
||||||
|
### RunOnce
|
||||||
|
|
||||||
|
The RunOnce mode is used to run the application once and exit. This is useful when providing your own external scheduler, like cron.
|
||||||
|
|
||||||
|
Set the `mode` to `RunOnce`.
|
||||||
|
|
||||||
|
### Scheduled
|
||||||
|
|
||||||
|
The Scheduled mode is used to run the application in a scheduled interval.
|
||||||
|
|
||||||
|
Set the `mode` to `Scheduled` and provide a cron expression for the `check_interval`.
|
||||||
|
|||||||
24
config.example.yaml
Normal file
24
config.example.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
ip_provider:
|
||||||
|
type: plain
|
||||||
|
config:
|
||||||
|
url: https://api.ipify.org
|
||||||
|
dns_provider:
|
||||||
|
type: ionos
|
||||||
|
config:
|
||||||
|
api_key: <your-api-key>
|
||||||
|
base_url: https://api.hosting.ionos.com/dns
|
||||||
|
notification_provider:
|
||||||
|
type: gotify
|
||||||
|
config:
|
||||||
|
url: <your-gotify-host>
|
||||||
|
token: <your-token>
|
||||||
|
priority: 0
|
||||||
|
domains:
|
||||||
|
- tld: example.com
|
||||||
|
subdomains:
|
||||||
|
- "@"
|
||||||
|
- www
|
||||||
|
check_interval: 0 0 0/6 * * * *
|
||||||
|
mode: Scheduled
|
||||||
|
log_level: info
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module realdnydns
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-co-op/gocron v1.37.0
|
||||||
|
github.com/go-co-op/gocron/v2 v2.18.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
)
|
||||||
47
go.sum
Normal file
47
go.sum
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
|
||||||
|
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.18.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
||||||
|
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
142
main.go
Normal file
142
main.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"realdnydns/pkg/config"
|
||||||
|
"realdnydns/pkg/dnsProvider"
|
||||||
|
ionos "realdnydns/pkg/dnsProvider/ionos"
|
||||||
|
"realdnydns/pkg/externalIpProvider"
|
||||||
|
plainExternalIpProvider "realdnydns/pkg/externalIpProvider/plain"
|
||||||
|
"realdnydns/pkg/notificationProvider"
|
||||||
|
notificationProviderConsole "realdnydns/pkg/notificationProvider/console"
|
||||||
|
gotify "realdnydns/pkg/notificationProvider/gotify"
|
||||||
|
"realdnydns/pkg/realDynDns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
}))
|
||||||
|
|
||||||
|
configClient := config.Config{}
|
||||||
|
err := configClient.Load("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to load config file", slog.String("error", err.Error()))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if configClient.LogLevel != "" {
|
||||||
|
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.Level(config.LogLevelMap[strings.ToLower(configClient.LogLevel)]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalIpProvider externalIpProvider.ExternalIpProvider
|
||||||
|
switch configClient.ExternalIPProvider.Type {
|
||||||
|
case "plain":
|
||||||
|
logger.Info("Using plain external IP provider", slog.String("external_ip_provider", "plain"))
|
||||||
|
|
||||||
|
var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig
|
||||||
|
err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create config",
|
||||||
|
slog.String("external_ip_provider", "plain"),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
externalIpProvider, err = plainExternalIpProvider.New(plainConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create plain external IP provider",
|
||||||
|
slog.String("external_ip_provider", "plain"),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Error("Unknown external IP provider", slog.String("external_ip_provider", configClient.ExternalIPProvider.Type))
|
||||||
|
panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsProvider dnsProvider.DNSProvider
|
||||||
|
switch configClient.DNSProvider.Type {
|
||||||
|
case "ionos":
|
||||||
|
logger.Info("Using IONOS DNS provider", slog.String("dns_provider", "ionos"))
|
||||||
|
|
||||||
|
var ionosConfig ionos.IONOSConfig
|
||||||
|
err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create IONOS DNS provider",
|
||||||
|
slog.String("dns_provider", "ionos"),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsProvider, err = ionos.NewIonos(&ionosConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create IONOS DNS provider",
|
||||||
|
slog.String("dns_provider", "ionos"),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Error("Unknown DNS provider", slog.String("dns_provider", configClient.DNSProvider.Type))
|
||||||
|
panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
var notificationProvider notificationProvider.NotificationProvider
|
||||||
|
switch configClient.NotificationProvider.Type {
|
||||||
|
case "gotify":
|
||||||
|
logger.Info("Using Gotify notification provider", slog.String("notification_provider", "gotify"))
|
||||||
|
|
||||||
|
var gotifyConfig gotify.NotificationProviderImplGotifyConfig
|
||||||
|
err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationProvider, err = gotify.New(gotifyConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create Gotify notification provider",
|
||||||
|
slog.String("notification_provider", "gotify"),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Info("Using console notification provider", slog.String("notification_provider", "console"))
|
||||||
|
|
||||||
|
notificationProvider = notificationProviderConsole.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains, logger.With(slog.String("service", "realDynDns")))
|
||||||
|
|
||||||
|
switch configClient.Mode {
|
||||||
|
case config.ScheduledMode:
|
||||||
|
logger.Info("Running in scheduled mode", slog.String("interval", configClient.CheckInterval))
|
||||||
|
|
||||||
|
schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create scheduler", slog.String("error", err.Error()))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Next run:", slog.String("time", job.NextRun().String()))
|
||||||
|
schedule.StartBlocking()
|
||||||
|
case config.RunOnceMode:
|
||||||
|
logger.Info("Running in run once mode")
|
||||||
|
|
||||||
|
_, err := rdd.RunOnce()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to run once", slog.String("error", err.Error()))
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
model/common/common.go
Normal file
9
model/common/common.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
type ARecord struct {
|
||||||
|
Domain string
|
||||||
|
IP string
|
||||||
|
TTL int
|
||||||
|
Prio int
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
15
pkg/config/__mocks__/testLoadCanFindFile.yaml
Normal file
15
pkg/config/__mocks__/testLoadCanFindFile.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
ip_provider:
|
||||||
|
type: plain
|
||||||
|
config:
|
||||||
|
url: https://example.com
|
||||||
|
dns_provider:
|
||||||
|
type: ionos
|
||||||
|
config:
|
||||||
|
api_key: exampleapikey
|
||||||
|
base_url: https://example.com
|
||||||
|
domains:
|
||||||
|
- tld: example.com
|
||||||
|
subdomains:
|
||||||
|
- "@"
|
||||||
|
check_interval: 0 0 * * * *
|
||||||
|
mode: RunOnce
|
||||||
2
pkg/config/__mocks__/testLoadInvalidMode.yaml
Normal file
2
pkg/config/__mocks__/testLoadInvalidMode.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mode: "InvalidMode"
|
||||||
|
check_interval: "5m"
|
||||||
3
pkg/config/__mocks__/testLoadInvalidYAML.yaml
Normal file
3
pkg/config/__mocks__/testLoadInvalidYAML.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mode: "Scheduled"
|
||||||
|
check_interval: "5m"
|
||||||
|
- invalid_content
|
||||||
3
pkg/config/__mocks__/testLoadMissingCheckInterval.yaml
Normal file
3
pkg/config/__mocks__/testLoadMissingCheckInterval.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mode: "Scheduled"
|
||||||
|
ip_provider:
|
||||||
|
type: "plain"
|
||||||
91
pkg/config/config.go
Normal file
91
pkg/config/config.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"`
|
||||||
|
DNSProvider DNSProviderConfig `yaml:"dns_provider"`
|
||||||
|
NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
|
||||||
|
Domains []DomainConfig `yaml:"domains"`
|
||||||
|
CheckInterval string `yaml:"check_interval"`
|
||||||
|
LogLevel string `yaml:"log_level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
RunOnceMode = "RunOnce"
|
||||||
|
ScheduledMode = "Scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LogLevelMap = map[string]slog.Level{
|
||||||
|
"debug": slog.LevelDebug,
|
||||||
|
"info": slog.LevelInfo,
|
||||||
|
"warn": slog.LevelWarn,
|
||||||
|
"error": slog.LevelError,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidLogLevel(level string) bool {
|
||||||
|
_, ok := LogLevelMap[strings.ToLower(level)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainConfig struct {
|
||||||
|
TLD string `yaml:"tld"`
|
||||||
|
Subdomains []string `yaml:"subdomains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalIpProviderConfig struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
ProviderConfig yaml.Node `yaml:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSProviderConfig struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
ProviderConfig yaml.Node `yaml:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationProviderConfig struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
ProviderConfig yaml.Node `yaml:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Load(filePath string) error {
|
||||||
|
inputConfig, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(inputConfig, c); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
return fmt.Errorf("failed to validate config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if c.Mode != RunOnceMode && c.Mode != ScheduledMode {
|
||||||
|
return errors.New("mode must be one of 'RunOnce' or 'Scheduled'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Mode == ScheduledMode && c.CheckInterval == "" {
|
||||||
|
return errors.New("check interval must be set when mode is 'Scheduled'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
|
||||||
|
return fmt.Errorf("log level must be one of 'debug', 'info', 'warn', 'error', but got %s", c.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
pkg/config/config_test.go
Normal file
52
pkg/config/config_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testFactoryFileRelatedError(fileName string, expectedErrorText string) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
c := Config{}
|
||||||
|
err := c.Load(fmt.Sprintf("./__mocks__/%s", fileName))
|
||||||
|
|
||||||
|
want := err != nil && err.Error() == expectedErrorText
|
||||||
|
|
||||||
|
if !want {
|
||||||
|
t.Fatalf("Expected error message %s, but got %s", expectedErrorText, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad(t *testing.T) {
|
||||||
|
t.Run("Can find file", testLoadCanFindFile())
|
||||||
|
t.Run("Cannot find file", testFactoryFileRelatedError(
|
||||||
|
"nonexistent.yaml",
|
||||||
|
"failed to read config file: open ./__mocks__/nonexistent.yaml: no such file or directory",
|
||||||
|
))
|
||||||
|
t.Run("Missing CheckInterval in Scheduled mode", testFactoryFileRelatedError(
|
||||||
|
"testLoadMissingCheckInterval.yaml",
|
||||||
|
"failed to validate config: check interval must be set when mode is 'Scheduled'",
|
||||||
|
))
|
||||||
|
t.Run("Invalid mode", testFactoryFileRelatedError(
|
||||||
|
"testLoadInvalidMode.yaml",
|
||||||
|
"failed to validate config: mode must be one of 'RunOnce' or 'Scheduled'",
|
||||||
|
))
|
||||||
|
t.Run("Invalid YAML", testFactoryFileRelatedError(
|
||||||
|
"testLoadInvalidYAML.yaml",
|
||||||
|
"failed to unmarshal config file: yaml: line 2: did not find expected key",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadCanFindFile() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
c := Config{}
|
||||||
|
err := c.Load("./__mocks__/testLoadCanFindFile.yaml")
|
||||||
|
|
||||||
|
want := err == nil && c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.Mode == "RunOnce"
|
||||||
|
|
||||||
|
if !want || err != nil {
|
||||||
|
t.Fatalf("Failed to load config file, expected no errors but got: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/dnsProvider/__mocks__/testNewCanCreateIonosProvider.yaml
Normal file
16
pkg/dnsProvider/__mocks__/testNewCanCreateIonosProvider.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
ip_provider:
|
||||||
|
type: plain
|
||||||
|
config:
|
||||||
|
url: https://ifconfig.me
|
||||||
|
dns_provider:
|
||||||
|
type: ionos
|
||||||
|
config:
|
||||||
|
api_key: exampleAPIKey
|
||||||
|
base_url: https://example.com
|
||||||
|
domains:
|
||||||
|
- tld: example.com
|
||||||
|
subdomains:
|
||||||
|
- "@"
|
||||||
|
- www
|
||||||
|
check_interval: 0 0 0/6 * * * *
|
||||||
11
pkg/dnsProvider/dnsProvider.go
Normal file
11
pkg/dnsProvider/dnsProvider.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package dnsProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"realdnydns/model/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DNSProvider interface {
|
||||||
|
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
|
||||||
|
GetRecord(tld string, subdomain string) (*common.ARecord, error)
|
||||||
|
}
|
||||||
224
pkg/dnsProvider/ionos/api/ionosAPI.go
Normal file
224
pkg/dnsProvider/ionos/api/ionosAPI.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package ionosAPI
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"realdnydns/model/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docs: https://developer.hosting.ionos.com/docs/dns
|
||||||
|
*/
|
||||||
|
type IonosAPI interface {
|
||||||
|
GetARecord(tld string, subdomain string) (*common.ARecord, error)
|
||||||
|
SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
|
||||||
|
GetZoneId(tld string) (string, error)
|
||||||
|
GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error)
|
||||||
|
HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IonosAPIImpl struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZonesResponse []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZoneResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Records []RecordResponse `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RootName string `json:"rootName"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ChangeDate string `json:"changeDate"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
Prio int `json:"prio"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeRecord struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
Prio int `json:"prio"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeRecordRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
Prio int `json:"prio"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(APIKey string, BaseURL string) IonosAPI {
|
||||||
|
return &IonosAPIImpl{
|
||||||
|
APIKey: APIKey,
|
||||||
|
BaseURL: BaseURL,
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
|
||||||
|
requestUrl, _ := url.Parse(i.BaseURL + path)
|
||||||
|
|
||||||
|
query := requestUrl.Query()
|
||||||
|
for key, value := range queryParams {
|
||||||
|
query.Add(key, value)
|
||||||
|
}
|
||||||
|
requestUrl.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, requestUrl.String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("X-API-Key", i.APIKey)
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
|
||||||
|
return i.HTTPClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
|
||||||
|
res, err := i.HttpCall("GET", "/v1/zones", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody := make([]byte, res.ContentLength)
|
||||||
|
res.Body.Read(responseBody)
|
||||||
|
|
||||||
|
zones := []ZoneResponse{}
|
||||||
|
json.Unmarshal(responseBody, &zones)
|
||||||
|
|
||||||
|
for _, z := range zones {
|
||||||
|
if z.Name == tld {
|
||||||
|
return z.Id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("zone not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
|
||||||
|
var domain string
|
||||||
|
if subdomain == "@" || subdomain == "" {
|
||||||
|
domain = tld
|
||||||
|
} else {
|
||||||
|
domain = subdomain + "." + tld
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody := make([]byte, res.ContentLength)
|
||||||
|
res.Body.Read(responseBody)
|
||||||
|
|
||||||
|
zone := ZoneResponse{}
|
||||||
|
json.Unmarshal(responseBody, &zone)
|
||||||
|
|
||||||
|
for _, record := range zone.Records {
|
||||||
|
if record.Type == recordType && record.Name == domain {
|
||||||
|
return record.Id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("record not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
zoneId, err := i.GetZoneId(tld)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recordId, err := i.GetRecordId(zoneId, tld, subdomain, "A")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRecordRequest, err := json.Marshal(ChangeRecordRequest{
|
||||||
|
Content: ip.String(),
|
||||||
|
TTL: ttl,
|
||||||
|
Prio: prio,
|
||||||
|
Disabled: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody := make([]byte, res.ContentLength)
|
||||||
|
res.Body.Read(responseBody)
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
return nil, errors.New("error updating record")
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRecord := ChangeRecord{}
|
||||||
|
json.Unmarshal(responseBody, &changeRecord)
|
||||||
|
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: changeRecord.Name,
|
||||||
|
IP: changeRecord.Content,
|
||||||
|
TTL: changeRecord.TTL,
|
||||||
|
Prio: changeRecord.Prio,
|
||||||
|
Disabled: changeRecord.Disabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
zoneId, err := ionos.GetZoneId(tld)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recordId, err := ionos.GetRecordId(zoneId, tld, subdomain, "A")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody := make([]byte, res.ContentLength)
|
||||||
|
res.Body.Read(responseBody)
|
||||||
|
|
||||||
|
record := RecordResponse{}
|
||||||
|
json.Unmarshal(responseBody, &record)
|
||||||
|
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: record.Name,
|
||||||
|
IP: record.Content,
|
||||||
|
TTL: record.TTL,
|
||||||
|
Prio: record.Prio,
|
||||||
|
Disabled: record.Disabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
285
pkg/dnsProvider/ionos/api/ionosAPI_test.go
Normal file
285
pkg/dnsProvider/ionos/api/ionosAPI_test.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
package ionosAPI
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func utilMockServerImpl() func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
zonesResponse := []ZoneResponse{
|
||||||
|
{
|
||||||
|
Name: "example.com",
|
||||||
|
Id: "1234567890",
|
||||||
|
Type: "NATIVE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "notTheExample.org",
|
||||||
|
Id: "0987654321",
|
||||||
|
Type: "SLAVE",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
zonesResponseJson, _ := json.Marshal(zonesResponse)
|
||||||
|
|
||||||
|
recordResponseSub := RecordResponse{
|
||||||
|
Id: "abcdefghij",
|
||||||
|
Name: "example.com",
|
||||||
|
RootName: "example.com",
|
||||||
|
Type: "A",
|
||||||
|
Content: "127.0.0.1",
|
||||||
|
ChangeDate: "2019-12-09T13:04:25.772Z",
|
||||||
|
TTL: 300,
|
||||||
|
Prio: 0,
|
||||||
|
Disabled: false,
|
||||||
|
}
|
||||||
|
recordResponseSubJson, _ := json.Marshal(recordResponseSub)
|
||||||
|
|
||||||
|
recordResponseTLD := RecordResponse{
|
||||||
|
Id: "jihgfedcba",
|
||||||
|
Name: "sub.example.com",
|
||||||
|
RootName: "example.com",
|
||||||
|
Type: "A",
|
||||||
|
Content: "127.0.0.2",
|
||||||
|
ChangeDate: "2019-12-09T13:04:25.772Z",
|
||||||
|
TTL: 300,
|
||||||
|
Prio: 0,
|
||||||
|
Disabled: false,
|
||||||
|
}
|
||||||
|
recordResponseTLDJson, _ := json.Marshal(recordResponseTLD)
|
||||||
|
|
||||||
|
zoneResponse := ZoneResponse{
|
||||||
|
Id: "1234567890",
|
||||||
|
Name: "example.com",
|
||||||
|
Type: "NATIVE",
|
||||||
|
Records: []RecordResponse{
|
||||||
|
recordResponseSub,
|
||||||
|
recordResponseTLD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
zoneResponseJson, _ := json.Marshal(zoneResponse)
|
||||||
|
|
||||||
|
changeRecord := ChangeRecord{
|
||||||
|
Name: "sub.example.com",
|
||||||
|
Type: "A",
|
||||||
|
Content: "127.0.0.1",
|
||||||
|
TTL: 300,
|
||||||
|
Prio: 0,
|
||||||
|
Disabled: false,
|
||||||
|
}
|
||||||
|
changeRecordJson, _ := json.Marshal(changeRecord)
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var response []byte
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
if r.RequestURI == "/v1/zones" {
|
||||||
|
response = zonesResponseJson
|
||||||
|
} else if r.RequestURI == "/v1/zones/1234567890?recordName=example.com&recordType=A" ||
|
||||||
|
r.RequestURI == "/v1/zones/1234567890?recordName=sub.example.com&recordType=A" {
|
||||||
|
response = zoneResponseJson
|
||||||
|
} else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" {
|
||||||
|
response = recordResponseSubJson
|
||||||
|
} else if r.RequestURI == "/v1/zones/1234567890/records/jihgfedcba" {
|
||||||
|
response = recordResponseTLDJson
|
||||||
|
}
|
||||||
|
} else if r.Method == "PUT" {
|
||||||
|
response = changeRecordJson
|
||||||
|
} else {
|
||||||
|
response = []byte("404")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHttpCall(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
ionosAPI := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
t.Run("returns response for GET request", testHttpCallGet(ionosAPI))
|
||||||
|
t.Run("returns response for PUT request", testHttpCallPut(ionosAPI))
|
||||||
|
t.Run("returns error for non existing endpoint", testHttpCallNonExistingEndpoint())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHttpCallGet(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
res, err := api.HttpCall("GET", "/v1/zones", nil, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHttpCallPut(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHttpCallNonExistingEndpoint() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
api := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 404 {
|
||||||
|
t.Fatalf("HttpCall() returned unexpected status code: %v instead of 404", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetZoneId(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
|
||||||
|
|
||||||
|
ionosAPI := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
t.Run("returns zoneId for tracked domain", testGetZoneIdSuccess(ionosAPI))
|
||||||
|
t.Run("returns error for non tracked domain", testGetZoneIdNoMatch(ionosAPI))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetZoneIdSuccess(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
zoneId, err := api.GetZoneId("example.com")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if zoneId != "1234567890" {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of 1234567890", zoneId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetZoneIdNoMatch(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
zoneId, err := api.GetZoneId("nonTrackedDomain.com")
|
||||||
|
|
||||||
|
if err == nil || zoneId != "" {
|
||||||
|
t.Fatalf("GetZoneId() did not return an error for a non tracked domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRecordId(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
ionosAPI := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
t.Run("returns zoneId for top level domain", testGetRecordIdForTopLevelDomain(ionosAPI))
|
||||||
|
t.Run("returns zoneId for subdomain", testGetRecordIdForSubdomain(ionosAPI))
|
||||||
|
t.Run("returns error for untracked subdomain", testGetRecordIdWithUntrackedSubdomain(ionosAPI))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRecordIdForTopLevelDomain(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
recordId, err := api.GetRecordId("1234567890", "example.com", "", "A")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordId != "abcdefghij" {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of abcdefghij", recordId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRecordIdForSubdomain(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
recordId, err := api.GetRecordId("1234567890", "example.com", "sub", "A")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordId != "jihgfedcba" {
|
||||||
|
t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of jihgfedcba", recordId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRecordIdWithUntrackedSubdomain(api IonosAPI) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
recordId, err := api.GetRecordId("1234567890", "example.com", "untrackedSub", "A")
|
||||||
|
|
||||||
|
if err == nil && recordId == "" {
|
||||||
|
t.Fatalf("GetZoneId() did not return an error for a non tracked domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetARecord(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
ionosAPI := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
t.Run("returns A record for top level domain", testGetARecordFor(ionosAPI, "example.com", ""))
|
||||||
|
t.Run("returns A record for subdomain", testGetARecordFor(ionosAPI, "example.com", "sub"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetARecordFor(api IonosAPI, tld string, subdomain string) func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
var domain string
|
||||||
|
if subdomain == "" {
|
||||||
|
domain = tld
|
||||||
|
} else {
|
||||||
|
domain = subdomain + "." + tld
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := api.GetARecord(tld, subdomain)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetARecord() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Domain != domain {
|
||||||
|
t.Fatalf("GetARecord() returned unexpected record for: %v instead of %v", record.Domain, domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetARecord(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl()))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
ionosAPI := New("dummyKey", mockServer.URL)
|
||||||
|
|
||||||
|
changedARecord, err := ionosAPI.SetARecord("example.com", "sub", net.ParseIP("127.0.0.1"), 300, 0, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetARecord() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changedARecord.Domain != "sub.example.com" {
|
||||||
|
t.Fatalf("SetARecord() returned unexpected record for: %v instead of sub.example.com", changedARecord.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pkg/dnsProvider/ionos/ionos.go
Normal file
40
pkg/dnsProvider/ionos/ionos.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package ionosDnsProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"realdnydns/model/common"
|
||||||
|
"realdnydns/pkg/dnsProvider"
|
||||||
|
ionosAPI "realdnydns/pkg/dnsProvider/ionos/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IONOS struct {
|
||||||
|
API ionosAPI.IonosAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
type IONOSConfig struct {
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) {
|
||||||
|
if config.APIKey == "" {
|
||||||
|
return nil, errors.New("api_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
return nil, errors.New("base_url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IONOS{
|
||||||
|
ionosAPI.New(config.APIKey, config.BaseURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ionos *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
return ionos.API.SetARecord(tld, subdomain, ip, ttl, prio, disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
return ionos.API.GetARecord(tld, subdomain)
|
||||||
|
}
|
||||||
225
pkg/dnsProvider/ionos/ionos_test.go
Normal file
225
pkg/dnsProvider/ionos/ionos_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package ionosDnsProvider_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
common "realdnydns/model/common"
|
||||||
|
ionosDnsProvider "realdnydns/pkg/dnsProvider/ionos"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
//t.Run("Config cannot be decoded", testNewConfigCannotBeDecoded())
|
||||||
|
t.Run("API key is required", testNewAPIKeyIsRequired())
|
||||||
|
t.Run("Base URL is required", testNewBaseURLIsRequired())
|
||||||
|
t.Run("Valid config", testNewValidConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewAPIKeyIsRequired() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
config := ionosDnsProvider.IONOSConfig{
|
||||||
|
BaseURL: "https://api.ionos.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ionosDnsProvider.NewIonos(&config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewBaseURLIsRequired() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
config := ionosDnsProvider.IONOSConfig{
|
||||||
|
APIKey: "1234",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ionosDnsProvider.NewIonos(&config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewValidConfig() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
config := ionosDnsProvider.IONOSConfig{
|
||||||
|
APIKey: "1234",
|
||||||
|
BaseURL: "https://api.ionos.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ionosDnsProvider.NewIonos(&config)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockIonosAPI struct {
|
||||||
|
SetARecordFunc func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
|
||||||
|
GetARecordFunc func(tld string, subdomain string) (*common.ARecord, error)
|
||||||
|
GetRecordIdFunc func(zoneId string, tld string, subdomain string, recordType string) (string, error)
|
||||||
|
GetZoneIdFunc func(tld string) (string, error)
|
||||||
|
HttpCallFunc func(method string, url string, body io.Reader) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockIonosAPI) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
return m.SetARecordFunc(tld, subdomain, ip, ttl, prio, disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockIonosAPI) GetARecord(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
return m.GetARecordFunc(tld, subdomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockIonosAPI) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
|
||||||
|
return m.GetRecordIdFunc(zoneId, tld, subdomain, recordType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockIonosAPI) GetZoneId(tld string) (string, error) {
|
||||||
|
return m.GetZoneIdFunc(tld)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) {
|
||||||
|
return m.HttpCallFunc(method, url, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRecord(t *testing.T) {
|
||||||
|
t.Run("API error", testUpdateRecordAPIError())
|
||||||
|
t.Run("API success", testUpdateRecordAPISuccess())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateRecordAPIError() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
api := &MockIonosAPI{
|
||||||
|
SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
return nil, errors.New("API error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := ionosDnsProvider.IONOS{
|
||||||
|
API: api,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateRecordAPISuccess() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
api := &MockIonosAPI{
|
||||||
|
SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: "sub",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
TTL: 60,
|
||||||
|
Prio: 0,
|
||||||
|
Disabled: false,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := ionosDnsProvider.IONOS{
|
||||||
|
API: api,
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Domain != "sub" {
|
||||||
|
t.Errorf("Expected sub, got %s", record.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.IP != "127.0.0.1" {
|
||||||
|
t.Errorf("Expected 127.0.0.1, got %s", record.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.TTL != 60 {
|
||||||
|
t.Errorf("Expected 60, got %d", record.TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Prio != 0 {
|
||||||
|
t.Errorf("Expected 0, got %d", record.Prio)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Disabled {
|
||||||
|
t.Error("Expected false, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRecord(t *testing.T) {
|
||||||
|
t.Run("API error", testGetRecordAPIError())
|
||||||
|
t.Run("API success", testGetRecordAPISuccess())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRecordAPIError() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
api := &MockIonosAPI{
|
||||||
|
GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
return nil, errors.New("API error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := ionosDnsProvider.IONOS{
|
||||||
|
API: api,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.GetRecord("example.com", "sub")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRecordAPISuccess() func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
api := &MockIonosAPI{
|
||||||
|
GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: "sub",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
TTL: 60,
|
||||||
|
Prio: 0,
|
||||||
|
Disabled: false,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := ionosDnsProvider.IONOS{
|
||||||
|
API: api,
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := provider.GetRecord("example.com", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Domain != "sub" {
|
||||||
|
t.Errorf("expected sub, got %s", record.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.IP != "127.0.0.1" {
|
||||||
|
t.Errorf("expected 127.0.0.1, got %s", record.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.TTL != 60 {
|
||||||
|
t.Errorf("expected 60, got %d", record.TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Prio != 0 {
|
||||||
|
t.Errorf("expected 0, got %d", record.Prio)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Disabled {
|
||||||
|
t.Error("expected false, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
pkg/externalIpProvider/externalIpProvider.go
Normal file
9
pkg/externalIpProvider/externalIpProvider.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package externalIpProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExternalIpProvider interface {
|
||||||
|
GetExternalIp() (net.IP, error)
|
||||||
|
}
|
||||||
60
pkg/externalIpProvider/plain/plain.go
Normal file
60
pkg/externalIpProvider/plain/plain.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package externalIpProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
externalIpProvider "realdnydns/pkg/externalIpProvider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExternalIpProviderImplPlain struct {
|
||||||
|
IPProviderURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlainExternalIpProviderConfig struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(config PlainExternalIpProviderConfig) (externalIpProvider.ExternalIpProvider, error) {
|
||||||
|
if config.Url == "" {
|
||||||
|
return nil, errors.New("url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ExternalIpProviderImplPlain{
|
||||||
|
config.Url,
|
||||||
|
&http.Client{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExternalIpProviderImplPlain) GetExternalIp() (net.IP, error) {
|
||||||
|
parsedUrl, err := url.Parse(p.IPProviderURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.HTTPClient.Get(parsedUrl.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
res.Body.Close()
|
||||||
|
return nil, errors.New("unexpected status code")
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedIp := net.ParseIP(string(responseBody))
|
||||||
|
if parsedIp == nil {
|
||||||
|
return nil, errors.New("unable to parse ip")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp, nil
|
||||||
|
}
|
||||||
150
pkg/externalIpProvider/plain/plain_test.go
Normal file
150
pkg/externalIpProvider/plain/plain_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package externalIpProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
t.Run("returns external ip provider", testNew())
|
||||||
|
t.Run("returns error when url is empty", testNewEmptyUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNew() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: "http://localhost",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatalf("NewPlain() returned nil provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewEmptyUrl() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
_, err := New(PlainExternalIpProviderConfig{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetExternal(t *testing.T) {
|
||||||
|
t.Run("returns external ip", testGetExternalIp())
|
||||||
|
t.Run("returns error when unable to parse ip", testGetExternalIpUnparsableIp())
|
||||||
|
t.Run("returns error when unexpected status code", testGetExternalIpUnexpectedStatusCode())
|
||||||
|
t.Run("returns error when unable to parse url", testGetExternalIpUnableToParseUrl())
|
||||||
|
t.Run("returns error when unable to get", testGetExternalIpUnableToGet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetExternalIp() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("127.0.0.1"))
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: mockServer.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := provider.GetExternalIp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip.String() != "127.0.0.1" {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected ip: %v instead of 127.0.0.1", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetExternalIpUnparsableIp() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("not an ip"))
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: mockServer.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.GetExternalIp()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetExternalIpUnexpectedStatusCode() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: mockServer.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.GetExternalIp()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetExternalIpUnableToParseUrl() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: "not a url !'§%&",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.GetExternalIp()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetExternalIpUnableToGet() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
// force error by using a non-responding url, beware of side effects
|
||||||
|
provider, err := New(PlainExternalIpProviderConfig{
|
||||||
|
Url: "http://localhost:1234",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlain() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.GetExternalIp()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/notificationProvider/console/console.go
Normal file
16
pkg/notificationProvider/console/console.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package notificationProviderConsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationProviderImplConsole struct{}
|
||||||
|
|
||||||
|
func New() *NotificationProviderImplConsole {
|
||||||
|
return &NotificationProviderImplConsole{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NotificationProviderImplConsole) SendNotification(title string, message string) error {
|
||||||
|
fmt.Printf("%s: %s\n", title, message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
89
pkg/notificationProvider/gotify/gotify.go
Normal file
89
pkg/notificationProvider/gotify/gotify.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package notificationProviderGotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationProviderImplGotifyConfig struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
Priority int `yaml:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationProviderImplGotify struct {
|
||||||
|
Url url.URL
|
||||||
|
Token string
|
||||||
|
Priority int
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(config NotificationProviderImplGotifyConfig) (*NotificationProviderImplGotify, error) {
|
||||||
|
if config.Url == "" {
|
||||||
|
return nil, fmt.Errorf("url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := url.Parse(config.Url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Token == "" {
|
||||||
|
return nil, fmt.Errorf("token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Priority < 0 || config.Priority > 4 {
|
||||||
|
return nil, fmt.Errorf("priority must be between 0 and 4")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &NotificationProviderImplGotify{
|
||||||
|
*url,
|
||||||
|
config.Token,
|
||||||
|
config.Priority,
|
||||||
|
&http.Client{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NotificationProviderImplGotify) SendNotification(title string, message string) error {
|
||||||
|
type GotifyMessage struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
messageJson, err := json.Marshal(GotifyMessage{
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
p.Priority,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
messageUrl := p.Url
|
||||||
|
messageUrl = *messageUrl.JoinPath("message")
|
||||||
|
queryParams := messageUrl.Query()
|
||||||
|
queryParams.Add("token", p.Token)
|
||||||
|
messageUrl.RawQuery = queryParams.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, err := p.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
81
pkg/notificationProvider/gotify/gotify_test.go
Normal file
81
pkg/notificationProvider/gotify/gotify_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package notificationProviderGotify_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gotify "realdnydns/pkg/notificationProvider/gotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
t.Run("URL is required", testNewEmptyUrl())
|
||||||
|
t.Run("Token is required", testNewEmptyToken())
|
||||||
|
t.Run("Priority must be between 0 and 4", testNewInvalidPriority())
|
||||||
|
t.Run("Sends POST request to url", testNewSendsPostRequest())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewEmptyUrl() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
|
||||||
|
Token: "1234",
|
||||||
|
Priority: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() != "url is required" {
|
||||||
|
t.Errorf("Expected error 'url is required', got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewEmptyToken() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
|
||||||
|
Url: "http://localhost:1234",
|
||||||
|
Priority: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() != "token is required" {
|
||||||
|
t.Errorf("Expected error 'token is required', got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewInvalidPriority() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
_, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
|
||||||
|
Url: "http://localhost:1234",
|
||||||
|
Token: "token",
|
||||||
|
Priority: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.Error() != "priority must be between 0 and 4" {
|
||||||
|
t.Errorf("Expected error 'priority must be between 0 and 4', got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewSendsPostRequest() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
provider, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{
|
||||||
|
Url: mockServer.URL,
|
||||||
|
Token: "1234",
|
||||||
|
Priority: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.SendNotification("title", "message")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendNotification() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
pkg/notificationProvider/notificationProvider.go
Normal file
5
pkg/notificationProvider/notificationProvider.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package notificationProvider
|
||||||
|
|
||||||
|
type NotificationProvider interface {
|
||||||
|
SendNotification(title string, message string) error
|
||||||
|
}
|
||||||
152
pkg/realDynDns/realDynDns.go
Normal file
152
pkg/realDynDns/realDynDns.go
Normal 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
|
||||||
|
}
|
||||||
120
pkg/realDynDns/realDynDns_test.go
Normal file
120
pkg/realDynDns/realDynDns_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package realDynDns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"realdnydns/model/common"
|
||||||
|
"realdnydns/pkg/config"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockExternalIpProvider struct {
|
||||||
|
ExternalIp string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockExternalIpProvider) GetExternalIp() (net.IP, error) {
|
||||||
|
return net.ParseIP(m.ExternalIp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockDNSProvider interface {
|
||||||
|
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
|
||||||
|
GetRecord(tld string, subdomain string) (*common.ARecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockDNSProviderImpl struct {
|
||||||
|
GetRecordIpResponse string
|
||||||
|
UpdateRecordIpResponse string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDNSProviderImpl) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
return &common.ARecord{
|
||||||
|
IP: m.GetRecordIpResponse,
|
||||||
|
TTL: 10,
|
||||||
|
Prio: 20,
|
||||||
|
Disabled: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDNSProviderImpl) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
return &common.ARecord{
|
||||||
|
IP: m.UpdateRecordIpResponse,
|
||||||
|
TTL: 10,
|
||||||
|
Prio: 20,
|
||||||
|
Disabled: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockedNotificationProvider struct{}
|
||||||
|
|
||||||
|
type MockedNotificationProviderImpl struct{}
|
||||||
|
|
||||||
|
func (m *MockedNotificationProviderImpl) SendNotification(title string, message string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAndApplyChanges(t *testing.T) {
|
||||||
|
t.Run("with changes", testDetectAndApplyChangesWithChanges())
|
||||||
|
t.Run("without changes", testDetectAndApplyChangesWithoutChanges())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetectAndApplyChangesWithChanges() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
changeDetector := New(&MockExternalIpProvider{
|
||||||
|
ExternalIp: "127.0.0.1",
|
||||||
|
}, &MockDNSProviderImpl{
|
||||||
|
GetRecordIpResponse: "127.0.0.2",
|
||||||
|
UpdateRecordIpResponse: "127.0.0.1",
|
||||||
|
}, &MockedNotificationProviderImpl{},
|
||||||
|
[]config.DomainConfig{
|
||||||
|
{
|
||||||
|
TLD: "example.com",
|
||||||
|
Subdomains: []string{
|
||||||
|
"test",
|
||||||
|
"@",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slog.Default(),
|
||||||
|
)
|
||||||
|
|
||||||
|
numberUpdated, err := changeDetector.RunOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if numberUpdated != 2 {
|
||||||
|
t.Errorf("expected numberUpdated to be 2, got %v", numberUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
changeDetector := New(&MockExternalIpProvider{
|
||||||
|
ExternalIp: "127.0.0.1",
|
||||||
|
}, &MockDNSProviderImpl{
|
||||||
|
GetRecordIpResponse: "127.0.0.1",
|
||||||
|
UpdateRecordIpResponse: "127.0.0.1",
|
||||||
|
}, &MockedNotificationProviderImpl{},
|
||||||
|
[]config.DomainConfig{
|
||||||
|
{
|
||||||
|
TLD: "example.com",
|
||||||
|
Subdomains: []string{
|
||||||
|
"test",
|
||||||
|
"@",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slog.Default(),
|
||||||
|
)
|
||||||
|
|
||||||
|
numberUpdated, err := changeDetector.RunOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if numberUpdated != 0 {
|
||||||
|
t.Errorf("expected numberUpdated to be 0, got %v", numberUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
renovate.json
Normal file
14
renovate.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"local>t.behrendt/renovate-configs:common",
|
||||||
|
"local>t.behrendt/renovate-configs:action"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["golang", "gomod", "go"],
|
||||||
|
"groupName": "go version",
|
||||||
|
"updateTypes": ["major", "minor", "patch"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user