Compare commits
56 Commits
90369bf147
...
0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bfb4419d3 | |||
| ac73cf14eb | |||
| 15431166e0 | |||
| caf13306d4 | |||
| 9291687b35 | |||
| 735a106826 | |||
| d0bce1b77c | |||
| 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@9255dc7a253b0ccc959486e2bca901246202afeb # v5
|
||||||
|
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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # 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@9841c96e72d4fc1ecec620e91e213895341cce86 # 0.1.22
|
||||||
|
id: tag
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
- run: |
|
||||||
|
git tag ${{ steps.tag.outputs.new-tag }}
|
||||||
|
git push origin ${{ steps.tag.outputs.new-tag }}
|
||||||
|
- name: Set output
|
||||||
|
run: |
|
||||||
|
echo "tag=${{ steps.tag.outputs.new-tag }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
create_manifest:
|
||||||
|
name: Create manifest
|
||||||
|
needs:
|
||||||
|
- build_and_push
|
||||||
|
- create_tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
|
- name: Get Metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Create manifest
|
||||||
|
run: |
|
||||||
|
docker manifest create ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }} \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-arm64
|
||||||
|
|
||||||
|
docker manifest push ${{ env.DOCKER_REGISTRY }}/t.behrendt/${{ steps.meta.outputs.REPO_NAME }}:${{ needs.create_tag.outputs.tag }}
|
||||||
53
.gitea/workflows/ci.yaml
Normal file
53
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GOPATH: /go_path
|
||||||
|
GOCACHE: /go_cache
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
runs-on:
|
||||||
|
- ubuntu-latest
|
||||||
|
- linux_amd64
|
||||||
|
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5
|
||||||
|
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
|
||||||
|
- name: Upload test-coverage
|
||||||
|
uses: ChristopherHX/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: realdnydns-test-coverage
|
||||||
|
path: |
|
||||||
|
lcov.info
|
||||||
|
retention-days: 1
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,3 +21,7 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
config.yaml
|
||||||
|
lcov.info
|
||||||
|
realdnydns
|
||||||
|
|||||||
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"]
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
test: test-unit test-race test-coverage
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
go test ./pkg/... -coverprofile=coverage.out
|
||||||
|
|
||||||
|
test-race:
|
||||||
|
go test ./pkg/... -race
|
||||||
|
|
||||||
|
test-coverage:
|
||||||
|
go tool gcov2lcov -infile coverage.out > lcov.info
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build
|
||||||
|
|
||||||
|
lint:
|
||||||
|
go tool golint ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
make build
|
||||||
|
./realdyndns
|
||||||
|
|
||||||
|
format:
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
check-format:
|
||||||
|
@OUTPUT=$$(gofmt -l .); \
|
||||||
|
if [ -n "$$OUTPUT" ]; then \
|
||||||
|
echo "Formatter failed for:"; \
|
||||||
|
echo "$$OUTPUT"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
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`.
|
||||||
|
|||||||
28
config.example.yaml
Normal file
28
config.example.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
# Optional: default TTL to use when patching records with missing TTL (default: 300)
|
||||||
|
# default_ttl: 300
|
||||||
|
# Optional: default priority to use when patching records with missing priority (default: 0)
|
||||||
|
# default_prio: 0
|
||||||
|
notification_provider:
|
||||||
|
type: gotify
|
||||||
|
config:
|
||||||
|
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
|
||||||
26
go.mod
Normal file
26
go.mod
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module realdnydns
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2
|
||||||
|
github.com/go-co-op/gocron v1.37.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jandelgado/gcov2lcov v1.1.1 // indirect
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/lint v0.0.0-20241112194109-818c5a804067 // indirect
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool (
|
||||||
|
github.com/jandelgado/gcov2lcov
|
||||||
|
golang.org/x/lint/golint
|
||||||
|
)
|
||||||
74
go.sum
Normal file
74
go.sum
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2 h1:EWz4kLLv7lSZx/F8K0/WxN5xeVNh7z4qbeDurZYBcNk=
|
||||||
|
gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2/go.mod h1:HZfdMF7X9LK/3FP1jJVzMWbM5BcgcZg/Qwx2WClFipg=
|
||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
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/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jandelgado/gcov2lcov v1.1.1 h1:CHUNoAglvb34DqmMoZchnzDbA3yjpzT8EoUvVqcAY+s=
|
||||||
|
github.com/jandelgado/gcov2lcov v1.1.1/go.mod h1:tMVUlMVtS1po2SB8UkADWhOT5Y5Q13XOce2AYU69JuI=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
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/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/lint v0.0.0-20241112194109-818c5a804067 h1:adDmSQyFTCiv19j015EGKJBoaa7ElV0Q1Wovb/4G7NA=
|
||||||
|
golang.org/x/lint v0.0.0-20241112194109-818c5a804067/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
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)
|
||||||
|
}
|
||||||
266
pkg/dnsProvider/ionos/ionos.go
Normal file
266
pkg/dnsProvider/ionos/ionos.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package ionosDnsProvider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"realdnydns/model/common"
|
||||||
|
"realdnydns/pkg/dnsProvider"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
ionosDnsClient "gitea.t000-n.de/t.behrendt/ionosDnsClient"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IONOS struct {
|
||||||
|
client *ionosDnsClient.ClientWithResponses
|
||||||
|
zoneIdMap map[string]string
|
||||||
|
defaultTtl int
|
||||||
|
defaultPrio int
|
||||||
|
zoneIdMapMutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type IONOSConfig struct {
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
DefaultTTL *int `yaml:"default_ttl,omitempty"`
|
||||||
|
DefaultPrio *int `yaml:"default_prio,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) {
|
||||||
|
if config.APIKey == "" {
|
||||||
|
return nil, errors.New("api_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
return nil, errors.New("base_url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []ionosDnsClient.ClientOption{
|
||||||
|
ionosDnsClient.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
|
||||||
|
req.Header.Set("X-API-Key", config.APIKey)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ionosDnsClient.NewClientWithResponses(config.BaseURL, options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values for TTL and Prio if not provided
|
||||||
|
defaultTtl := 300
|
||||||
|
if config.DefaultTTL != nil {
|
||||||
|
defaultTtl = *config.DefaultTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPrio := 0
|
||||||
|
if config.DefaultPrio != nil {
|
||||||
|
defaultPrio = *config.DefaultPrio
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IONOS{
|
||||||
|
client: client,
|
||||||
|
zoneIdMap: make(map[string]string),
|
||||||
|
defaultTtl: defaultTtl,
|
||||||
|
defaultPrio: defaultPrio,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
|
||||||
|
|
||||||
|
zoneId, err := i.getZoneIdForTld(tld)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := assembleTldSubdomainToDomain(tld, subdomain)
|
||||||
|
recordId, err := i.getRecordIdForDomain(zoneId, domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ipString := ip.String()
|
||||||
|
res, err := i.client.UpdateRecordWithResponse(context.Background(), zoneId, recordId, ionosDnsClient.RecordUpdate{
|
||||||
|
Content: &ipString,
|
||||||
|
Ttl: &ttl,
|
||||||
|
Prio: &prio,
|
||||||
|
Disabled: &disabled,
|
||||||
|
})
|
||||||
|
if err != nil || res.StatusCode() != 200 {
|
||||||
|
return nil, errors.New("failed to update record")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.JSON200 == nil {
|
||||||
|
return nil, errors.New("record response is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := *res.JSON200
|
||||||
|
if record.Name == nil || record.Content == nil {
|
||||||
|
return nil, errors.New("record is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle optional fields with defaults
|
||||||
|
recordTtl := 0
|
||||||
|
if record.Ttl != nil {
|
||||||
|
recordTtl = *record.Ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPrio := 0
|
||||||
|
if record.Prio != nil {
|
||||||
|
recordPrio = *record.Prio
|
||||||
|
}
|
||||||
|
|
||||||
|
recordDisabled := false
|
||||||
|
if record.Disabled != nil {
|
||||||
|
recordDisabled = *record.Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: *record.Name,
|
||||||
|
IP: *record.Content,
|
||||||
|
TTL: recordTtl,
|
||||||
|
Prio: recordPrio,
|
||||||
|
Disabled: recordDisabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IONOS) getZoneIdForTld(tld string) (string, error) {
|
||||||
|
i.zoneIdMapMutex.Lock()
|
||||||
|
defer i.zoneIdMapMutex.Unlock()
|
||||||
|
if zoneId, ok := i.zoneIdMap[tld]; ok {
|
||||||
|
return zoneId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := i.client.GetZonesWithResponse(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if res.StatusCode() != 200 {
|
||||||
|
return "", errors.New("failed to get zones")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.JSON200 == nil {
|
||||||
|
return "", errors.New("zones response is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
zones := *res.JSON200
|
||||||
|
|
||||||
|
for _, zone := range zones {
|
||||||
|
if *zone.Name == tld {
|
||||||
|
if zone.Id == nil || *zone.Id == "" {
|
||||||
|
return "", errors.New("zone id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
i.zoneIdMap[tld] = *zone.Id
|
||||||
|
return *zone.Id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("no zone found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordTypeA = "A"
|
||||||
|
|
||||||
|
func assembleTldSubdomainToDomain(tld string, subdomain string) string {
|
||||||
|
if subdomain == "@" || subdomain == "" {
|
||||||
|
return tld
|
||||||
|
}
|
||||||
|
return subdomain + "." + tld
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IONOS) getRecordIdForDomain(zoneId string, domain string) (string, error) {
|
||||||
|
res, err := i.client.GetZoneWithResponse(context.Background(), zoneId, &ionosDnsClient.GetZoneParams{
|
||||||
|
RecordName: &domain,
|
||||||
|
RecordType: &recordTypeA,
|
||||||
|
})
|
||||||
|
if err != nil || res.StatusCode() != 200 {
|
||||||
|
return "", errors.New("failed to get zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.JSON200 == nil {
|
||||||
|
return "", errors.New("zone response is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
zone := *res.JSON200
|
||||||
|
|
||||||
|
for _, record := range *zone.Records {
|
||||||
|
if *record.Name == domain && *record.Type == ionosDnsClient.RecordTypes("A") {
|
||||||
|
return *record.Id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("no record found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
|
||||||
|
zoneId, err := i.getZoneIdForTld(tld)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := assembleTldSubdomainToDomain(tld, subdomain)
|
||||||
|
recordId, err := i.getRecordIdForDomain(zoneId, domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := i.client.GetRecordWithResponse(context.Background(), zoneId, recordId)
|
||||||
|
if err != nil || res.StatusCode() != 200 || res.JSON200 == nil {
|
||||||
|
return nil, errors.New("failed to get record")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := *res.JSON200
|
||||||
|
|
||||||
|
if record.Name == nil || *record.Name == "" || *record.Name != domain {
|
||||||
|
return nil, errors.New("record name does not match or is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Content == nil || *record.Content == "" {
|
||||||
|
return nil, errors.New("record content is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
ttl := i.defaultTtl
|
||||||
|
if record.Ttl != nil {
|
||||||
|
ttl = *record.Ttl
|
||||||
|
} else {
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
prio := i.defaultPrio
|
||||||
|
if record.Prio != nil {
|
||||||
|
prio = *record.Prio
|
||||||
|
} else {
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
if record.Disabled != nil {
|
||||||
|
disabled = *record.Disabled
|
||||||
|
} else {
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// realDynDns requires every field to be set, so we need to update the record if any of the fields are missing
|
||||||
|
if needsUpdate {
|
||||||
|
ip := net.ParseIP(*record.Content)
|
||||||
|
if ip == nil {
|
||||||
|
return nil, errors.New("invalid IP address in record content")
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedRecord, err := i.UpdateRecord(tld, subdomain, ip, ttl, prio, disabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return updatedRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &common.ARecord{
|
||||||
|
Domain: *record.Name,
|
||||||
|
IP: *record.Content,
|
||||||
|
TTL: ttl,
|
||||||
|
Prio: prio,
|
||||||
|
Disabled: disabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
1464
pkg/dnsProvider/ionos/ionos_test.go
Normal file
1464
pkg/dnsProvider/ionos/ionos_test.go
Normal file
File diff suppressed because it is too large
Load Diff
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
renovate.json
Normal file
22
renovate.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"local>t.behrendt/renovate-configs:common",
|
||||||
|
"local>t.behrendt/renovate-configs:action"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": [
|
||||||
|
"golang",
|
||||||
|
"gomod",
|
||||||
|
"go"
|
||||||
|
],
|
||||||
|
"groupName": "go version",
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"major",
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user