5 Commits

Author SHA1 Message Date
ed1f9c3ed1 chore: update example and main
All checks were successful
CI / test (pull_request) Successful in 1m3s
2024-12-23 14:13:29 +01:00
9478149a20 fix: ionos API content-type 2024-12-23 14:13:18 +01:00
3c8b6ef264 fix: gotify notification provider 2024-12-23 14:13:08 +01:00
6333b75775 feat: add mode selection 2024-12-23 14:12:54 +01:00
75754538df chore: run go mod tidy 2024-12-23 14:12:06 +01:00
17 changed files with 110 additions and 368 deletions

View File

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

View File

@@ -13,14 +13,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 uses: actions/checkout@v4
- name: Setup go - name: Setup go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Create cache key - name: Create cache key
uses: https://gitea.com/actions/go-hashfiles@264ae76b7e50173ce71ed7da4b48e5e517f3f9ec # v0.0.1 uses: https://gitea.com/actions/go-hashfiles@v0.0.1
id: hash-go id: hash-go
with: with:
patterns: | patterns: |
@@ -28,7 +28,7 @@ jobs:
go.sum go.sum
- name: cache go - name: cache go
id: cache-go id: cache-go
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 uses: actions/cache@v4
with: with:
path: | path: |
/go_path /go_path
@@ -40,5 +40,3 @@ jobs:
run: make build run: make build
- name: test - name: test
run: make test run: make test
- name: check:format
run: make check-format

View File

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

View File

@@ -6,18 +6,3 @@ build:
lint: lint:
golint ./... golint ./...
run:
make build
./realdyndns
format:
gofmt -w .
check-format:
@OUTPUT=$$(gofmt -l .); \
if [ -n "$$OUTPUT" ]; then \
echo "Formatter failed for:"; \
echo "$$OUTPUT"; \
exit 1; \
fi

View File

@@ -1,12 +1,10 @@
# realDynDNS # realDynDNS
RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries. RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries.
This service requires your DNS provider to expose an API that allows your DNS entries to be changed. This service requires your DNS provider to expose an API that allows your DNS entries to be changed.
A service that provides your current external IP is also required. A service that provides your current external IP is also required.
## Configuration ## Configuration
The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains. The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains.
Configuration of the IP provider and the DNS provider is mandatory. Configuration of the IP provider and the DNS provider is mandatory.
@@ -37,52 +35,40 @@ domains:
- "@" - "@"
- www - www
check_interval: 0 0 0/6 * * * * check_interval: 0 0 0/6 * * * *
mode: Scheduled
log_level: info
``` ```
The config file is expected to be in the same directory as the binary and called `config.yaml`. For the OCR image, the root directory is `/app`. The config file is expected to be in the same directory as the binary and called `config.yaml`. For the OCR image, the root directory is `/app`.
## DNS Providers ## DNS Providers
The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported: The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported:
### IONOS ### IONOS
IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted). IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted).
```yaml ```yaml
api_key: <your-api-key> api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns base_url: https://api.hosting.ionos.com/dns
``` ```
## External IP Providers ## External IP Providers
The external IP provider is used to get your current external IP. Currently the following providers are supported: The external IP provider is used to get your current external IP. Currently the following providers are supported:
### Plain ### Plain
Any provider that returns your IP as plain text can be used. The following configuration is required: Any provider that returns your IP as plain text can be used. The following configuration is required:
```yaml ```yaml
url: <your-providers-URL> url: <your-providers-URL>
``` ```
Examples for providers are: Examples for providers are:
- https://ifconfig.me - https://ifconfig.me
- https://api.ipify.org - https://api.ipify.org
## Notification Providers ## Notification Providers
The notification provider is used to send notifications whena IP address changes and a DNS record is updated. The notification provider is used to send notifications whena IP address changes and a DNS record is updated.
### Console ### Console
The console notification provider is used to print the notification to the console. This is the default notification provider. The console notification provider is used to print the notification to the console. This is the default notification provider.
### Gotify ### Gotify
The Gotify notification provider is used to send notifications to a Gotify server. The Gotify notification provider is used to send notifications to a Gotify server.
```yaml ```yaml
@@ -92,19 +78,3 @@ priority: 0
``` ```
The priority must be between 0 and 4. The priority must be between 0 and 4.
## Mode Selection
Two modes are available:
### RunOnce
The RunOnce mode is used to run the application once and exit. This is useful when providing your own external scheduler, like cron.
Set the `mode` to `RunOnce`.
### Scheduled
The Scheduled mode is used to run the application in a scheduled interval.
Set the `mode` to `Scheduled` and provide a cron expression for the `check_interval`.

View File

@@ -21,4 +21,3 @@ domains:
- www - www
check_interval: 0 0 0/6 * * * * check_interval: 0 0 0/6 * * * *
mode: Scheduled mode: Scheduled
log_level: info

5
go.mod
View File

@@ -1,16 +1,15 @@
module realdnydns module realdnydns
go 1.25.0 go 1.20
require ( require (
github.com/go-co-op/gocron v1.37.0 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 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
) )

6
go.sum
View File

@@ -4,7 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/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.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -31,9 +30,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=

79
main.go
View File

@@ -2,9 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"log/slog"
"os"
"strings"
"realdnydns/pkg/config" "realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider" "realdnydns/pkg/dnsProvider"
@@ -18,84 +15,48 @@ import (
) )
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
configClient := config.Config{} configClient := config.Config{}
err := configClient.Load("config.yaml") err := configClient.Load("config.yaml")
if err != nil { if err != nil {
logger.Error("Failed to load config file", slog.String("error", err.Error()))
panic(err) panic(err)
} }
if configClient.LogLevel != "" {
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.Level(config.LogLevelMap[strings.ToLower(configClient.LogLevel)]),
}))
}
var externalIpProvider externalIpProvider.ExternalIpProvider var externalIpProvider externalIpProvider.ExternalIpProvider
switch configClient.ExternalIPProvider.Type { switch configClient.ExternalIPProvider.Type {
case "plain": case "plain":
logger.Info("Using plain external IP provider", slog.String("external_ip_provider", "plain"))
var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig
err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig) err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig)
if err != nil { if err != nil {
logger.Error("Failed to create config",
slog.String("external_ip_provider", "plain"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
externalIpProvider, err = plainExternalIpProvider.New(plainConfig) externalIpProvider, err = plainExternalIpProvider.New(plainConfig)
if err != nil { 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) panic(err)
} }
default: default:
logger.Error("Unknown external IP provider", slog.String("external_ip_provider", configClient.ExternalIPProvider.Type))
panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type)) panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type))
} }
var dnsProvider dnsProvider.DNSProvider var dnsProvider dnsProvider.DNSProvider
switch configClient.DNSProvider.Type { switch configClient.DNSProvider.Type {
case "ionos": case "ionos":
logger.Info("Using IONOS DNS provider", slog.String("dns_provider", "ionos"))
var ionosConfig ionos.IONOSConfig var ionosConfig ionos.IONOSConfig
err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig) err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig)
if err != nil { if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
dnsProvider, err = ionos.NewIonos(&ionosConfig) dnsProvider, err = ionos.NewIonos(&ionosConfig)
if err != nil { if err != nil {
logger.Error("Failed to create IONOS DNS provider",
slog.String("dns_provider", "ionos"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
default: default:
logger.Error("Unknown DNS provider", slog.String("dns_provider", configClient.DNSProvider.Type))
panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type)) panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type))
} }
var notificationProvider notificationProvider.NotificationProvider var notificationProvider notificationProvider.NotificationProvider
switch configClient.NotificationProvider.Type { switch configClient.NotificationProvider.Type {
case "gotify": case "gotify":
logger.Info("Using Gotify notification provider", slog.String("notification_provider", "gotify"))
var gotifyConfig gotify.NotificationProviderImplGotifyConfig var gotifyConfig gotify.NotificationProviderImplGotifyConfig
err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig) err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig)
if err != nil { if err != nil {
@@ -104,39 +65,29 @@ func main() {
notificationProvider, err = gotify.New(gotifyConfig) notificationProvider, err = gotify.New(gotifyConfig)
if err != nil { if err != nil {
logger.Error("Failed to create Gotify notification provider",
slog.String("notification_provider", "gotify"),
slog.String("error", err.Error()),
)
panic(err) panic(err)
} }
default: default:
logger.Info("Using console notification provider", slog.String("notification_provider", "console"))
notificationProvider = notificationProviderConsole.New() notificationProvider = notificationProviderConsole.New()
} }
rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains, logger.With(slog.String("service", "realDynDns"))) rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains)
switch configClient.Mode { switch configClient.Mode {
case config.ScheduledMode: case config.ScheduledMode:
logger.Info("Running in scheduled mode", slog.String("interval", configClient.CheckInterval)) schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval)
if err != nil {
panic(err)
}
schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval) fmt.Println("Starting scheduler")
if err != nil { fmt.Println("Next run:", job.NextRun())
logger.Error("Failed to create scheduler", slog.String("error", err.Error())) schedule.StartBlocking()
panic(err) case config.RunOnceMode:
} numberOfChanges, err := rdd.RunOnce()
if err != nil {
logger.Info("Next run:", slog.String("time", job.NextRun().String())) panic(err)
schedule.StartBlocking() }
case config.RunOnceMode: fmt.Println("Number of changes:", numberOfChanges)
logger.Info("Running in run once mode")
_, err := rdd.RunOnce()
if err != nil {
logger.Error("Failed to run once", slog.String("error", err.Error()))
panic(err)
}
} }
} }

View File

@@ -3,9 +3,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -17,26 +15,13 @@ type Config struct {
NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"` NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
Domains []DomainConfig `yaml:"domains"` Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"` CheckInterval string `yaml:"check_interval"`
LogLevel string `yaml:"log_level"`
} }
const ( const (
RunOnceMode = "RunOnce" RunOnceMode = "RunOnce"
ScheduledMode = "Scheduled" 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 { type DomainConfig struct {
TLD string `yaml:"tld"` TLD string `yaml:"tld"`
Subdomains []string `yaml:"subdomains"` Subdomains []string `yaml:"subdomains"`
@@ -71,7 +56,7 @@ func (c *Config) Load(filePath string) error {
return fmt.Errorf("failed to validate config: %w", err) return fmt.Errorf("failed to validate config: %w", err)
} }
return nil return nil;
} }
func (c *Config) validate() error { func (c *Config) validate() error {
@@ -83,9 +68,5 @@ func (c *Config) validate() error {
return errors.New("check interval must be set when mode is 'Scheduled'") return errors.New("check interval must be set when mode is 'Scheduled'")
} }
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) { return nil;
return fmt.Errorf("log level must be one of 'debug', 'info', 'warn', 'error', but got %s", c.LogLevel)
}
return nil
} }

View File

@@ -7,7 +7,6 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"realdnydns/model/common" "realdnydns/model/common"
) )
@@ -19,7 +18,7 @@ type IonosAPI interface {
SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetZoneId(tld string) (string, error) GetZoneId(tld string) (string, error)
GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error)
HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) HttpCall(method string, url string, body io.Reader) (*http.Response, error)
} }
type IonosAPIImpl struct { type IonosAPIImpl struct {
@@ -77,16 +76,8 @@ func New(APIKey string, BaseURL string) IonosAPI {
} }
} }
func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) { func (i *IonosAPIImpl) HttpCall(method string, url string, body io.Reader) (*http.Response, error) {
requestUrl, _ := url.Parse(i.BaseURL + path) req, err := http.NewRequest(method, i.BaseURL+url, body)
query := requestUrl.Query()
for key, value := range queryParams {
query.Add(key, value)
}
requestUrl.RawQuery = query.Encode()
req, err := http.NewRequest(method, requestUrl.String(), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -99,7 +90,7 @@ func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, quer
} }
func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) { func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
res, err := i.HttpCall("GET", "/v1/zones", nil, nil) res, err := i.HttpCall("GET", "/v1/zones", nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -120,14 +111,7 @@ func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) {
} }
func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) {
var domain string res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil)
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -138,6 +122,13 @@ func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string,
zone := ZoneResponse{} zone := ZoneResponse{}
json.Unmarshal(responseBody, &zone) json.Unmarshal(responseBody, &zone)
var domain string
if subdomain == "@" || subdomain == "" {
domain = tld
} else {
domain = subdomain + "." + tld
}
for _, record := range zone.Records { for _, record := range zone.Records {
if record.Type == recordType && record.Name == domain { if record.Type == recordType && record.Name == domain {
return record.Id, nil return record.Id, nil
@@ -168,7 +159,7 @@ func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl i
return nil, err return nil, err
} }
res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil) res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -180,6 +171,8 @@ func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl i
return nil, errors.New("error updating record") return nil, errors.New("error updating record")
} }
changeRecord := ChangeRecord{} changeRecord := ChangeRecord{}
json.Unmarshal(responseBody, &changeRecord) json.Unmarshal(responseBody, &changeRecord)
@@ -203,7 +196,7 @@ func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARe
return nil, err return nil, err
} }
res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil) res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ package realDynDns
import ( import (
"fmt" "fmt"
"log/slog"
"sync"
"time" "time"
"realdnydns/pkg/config" "realdnydns/pkg/config"
@@ -19,7 +17,6 @@ type ChangeDetector struct {
dnsProvider dnsProvider.DNSProvider dnsProvider dnsProvider.DNSProvider
notificationProvider notificationProvider.NotificationProvider notificationProvider notificationProvider.NotificationProvider
domains []config.DomainConfig domains []config.DomainConfig
logger *slog.Logger
} }
func New( func New(
@@ -27,14 +24,12 @@ func New(
dnsProvider dnsProvider.DNSProvider, dnsProvider dnsProvider.DNSProvider,
notificationProvider notificationProvider.NotificationProvider, notificationProvider notificationProvider.NotificationProvider,
domains []config.DomainConfig, domains []config.DomainConfig,
logger *slog.Logger,
) ChangeDetector { ) ChangeDetector {
return ChangeDetector{ return ChangeDetector{
externalIpProvider: externalIpProvider, externalIpProvider: externalIpProvider,
dnsProvider: dnsProvider, dnsProvider: dnsProvider,
notificationProvider: notificationProvider, notificationProvider: notificationProvider,
domains: domains, domains: domains,
logger: logger,
} }
} }
@@ -63,90 +58,37 @@ func (c *ChangeDetector) RunOnce() (int, error) {
} }
func (c *ChangeDetector) detectAndApplyChanges() (int, error) { func (c *ChangeDetector) detectAndApplyChanges() (int, error) {
c.logger.Info("Detecting and applying changes")
externalIp, err := c.externalIpProvider.GetExternalIp() externalIp, err := c.externalIpProvider.GetExternalIp()
if err != nil { if err != nil {
c.logger.Error("Failed to retrieve external IP", slog.String("error", err.Error()))
return 0, err return 0, err
} }
var wg sync.WaitGroup var numberUpdated int
numberUpdatedChannel := make(chan int)
for _, domain := range c.domains { for _, domain := range c.domains {
for _, subdomain := range domain.Subdomains { for _, subdomain := range domain.Subdomains {
wg.Add(1) currentRecord, err := c.dnsProvider.GetRecord(domain.TLD, subdomain)
if err != nil {
return numberUpdated, err
}
go func(domain config.DomainConfig, subdomain string) { if currentRecord.IP != externalIp.String() {
defer wg.Done() err = c.notificationProvider.SendNotification(
fmt.Sprintf("Update %s.%s", subdomain, domain.TLD),
c.logger.Info("Checking record", fmt.Sprintf("The IP of %s has changed from %s to %s", domain.TLD, currentRecord.IP, externalIp.String()),
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
) )
currentRecord, err := c.dnsProvider.GetRecord(domain.TLD, subdomain)
if err != nil { if err != nil {
c.logger.Error("Failed to retrieve record", return numberUpdated, err
slog.String("error", err.Error()),
slog.String("tld", domain.TLD),
slog.String("subdomain", subdomain),
)
return
} }
if currentRecord.IP != externalIp.String() { _, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled)
c.logger.Info("Record has changed", numberUpdated++
slog.String("tld", domain.TLD), if err != nil {
slog.String("subdomain", subdomain), return numberUpdated, err
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 return numberUpdated, nil
} }

View File

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

View File

@@ -1,14 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>t.behrendt/renovate-configs:common",
"local>t.behrendt/renovate-configs:action"
],
"packageRules": [
{
"matchPackageNames": ["golang", "gomod", "go"],
"groupName": "go version",
"updateTypes": ["major", "minor", "patch"]
}
]
}