├── .gitignore ├── go.mod ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── Makefile ├── scripts └── make-releases.sh ├── internal ├── logger.go ├── ip.go ├── config.go ├── cache.go └── dns_configurator.go ├── pkg └── netcup │ ├── request.go │ ├── response.go │ └── client.go ├── LICENSE ├── cmd ├── dyndns-netcup-go │ └── main.go └── dyndns-netcup-docker │ └── main.go ├── config └── example.yml ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | dyndns-netcup-go 3 | main 4 | dist/ 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Hentra/dyndns-netcup-go 2 | 3 | go 1.16 4 | 5 | require ( 6 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 // indirect 7 | golang.org/x/tools v0.1.0 // indirect 8 | gopkg.in/yaml.v2 v2.2.8 9 | ) 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.18 20 | id: go 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | - name: CI Tasks 24 | run: make ci 25 | 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIST_DIR ?= $(shell pwd)/dist 2 | 3 | ci: clean lint 4 | .PHONY: ci 5 | 6 | build: 7 | @echo "Building release artifacts" 8 | ./scripts/make-releases.sh $(DIST_DIR) 9 | .PHONY: build 10 | 11 | clean: 12 | @echo "Cleaning artifacts" 13 | @rm -rf $(DIST_DIR) 14 | .PHONY: clean 15 | 16 | lint: 17 | @echo "Linting sources" 18 | @docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:v1.43.0 golangci-lint run -v 19 | .PHONY: lint 20 | 21 | docker-build: 22 | @echo "Building docker image" 23 | @docker build -t dyndns-netcup-go -f ./build/package/Dockerfile . 24 | .PHONY: docker-build 25 | -------------------------------------------------------------------------------- /scripts/make-releases.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BIN_DIR="$1" 4 | 5 | [ -n "$BIN_DIR" ] || { 6 | echo "No directory for the binary files specified." 7 | exit 1 8 | } 9 | 10 | mkdir -p "$BIN_DIR" 11 | rm -rf "$BIN_DIR/*" 12 | 13 | ARCHS="windows,amd64,windows.exe linux,amd64,linux linux,arm,linux-arm linux,arm64,linux-arm64 darwin,amd64,macos" 14 | 15 | for arch in $ARCHS; do IFS=","; set -- $arch 16 | env GOOS=$1 GOARCH=$2 go build -o "$BIN_DIR/dyndns-netcup-go-$3" ./cmd/dyndns-netcup-go/main.go 17 | done 18 | 19 | for file in "$BIN_DIR"/*; do 20 | tar -czvf $file.tar.gz $file 21 | openssl dgst -sha256 $file > $file.sha256 22 | rm $file 23 | done 24 | -------------------------------------------------------------------------------- /internal/logger.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "log" 4 | 5 | // Logger represents an logger instance 6 | type Logger struct { 7 | verbose bool 8 | } 9 | 10 | // NewLogger creates a Logger instance with given verboseness 11 | func NewLogger(verbose bool) *Logger { 12 | return &Logger{verbose} 13 | } 14 | 15 | // Info logs a info message which will only show when verbose is set 16 | func (l *Logger) Info(msg string, v ...interface{}) { 17 | if l.verbose { 18 | log.Printf(msg, v...) 19 | } 20 | } 21 | 22 | // Warning will log a warning message 23 | func (l *Logger) Warning(msg string, v ...interface{}) { 24 | log.Printf("[Warning]: "+msg, v...) 25 | } 26 | 27 | // Error will log an error message and exit 28 | func (l *Logger) Error(v ...interface{}) { 29 | log.Fatal(v...) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/netcup/request.go: -------------------------------------------------------------------------------- 1 | package netcup 2 | 3 | // Request represents a request to the netcup api. 4 | type Request struct { 5 | Action string `json:"action"` 6 | Param Params `json:"param"` 7 | } 8 | 9 | // NewRequest returns a new Request struct by a action and parameters. 10 | func NewRequest(action string, params *Params) *Request { 11 | return &Request{ 12 | Action: action, 13 | Param: *params, 14 | } 15 | } 16 | 17 | // Params represent parameters to be send to the netcup api. 18 | type Params map[string]interface{} 19 | 20 | // NewParams returns a new Params struct. 21 | func NewParams() Params { 22 | return make(map[string]interface{}) 23 | } 24 | 25 | // AddParam adds a parameter with a specified key and value 26 | func (p Params) AddParam(key string, value interface{}) { 27 | p[key] = value 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Henri Burau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/ip.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | ) 7 | 8 | // AddrInfo represents the ip addresses of the host 9 | type AddrInfo struct { 10 | IPv4 string 11 | IPv6 string 12 | } 13 | 14 | // GetAddrInfo retrieves an AddrInfo instance 15 | func GetAddrInfo(ipv4 bool, ipv6 bool) (*AddrInfo, error) { 16 | adresses := &AddrInfo{} 17 | 18 | if ipv4 { 19 | address, err := getIPv4() 20 | if err != nil { 21 | return nil, err 22 | } 23 | adresses.IPv4 = address 24 | } 25 | 26 | if ipv6 { 27 | address, err := getIPv6() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | adresses.IPv6 = address 33 | } 34 | 35 | return adresses, nil 36 | } 37 | 38 | func getIPv4() (string, error) { 39 | return do("https://api.ipify.org?format=text") 40 | } 41 | 42 | func getIPv6() (string, error) { 43 | return do("https://api6.ipify.org?format=text") 44 | } 45 | 46 | func do(url string) (string, error) { 47 | resp, err := http.Get(url) 48 | if err != nil { 49 | return "", err 50 | } 51 | defer resp.Body.Close() 52 | ip, err := ioutil.ReadAll(resp.Body) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | return string(ip), nil 58 | } 59 | -------------------------------------------------------------------------------- /cmd/dyndns-netcup-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "time" 6 | 7 | "github.com/Hentra/dyndns-netcup-go/internal" 8 | ) 9 | 10 | const ( 11 | defaultConfigFile = "config.yml" 12 | configUsage = "Specify location of the config file" 13 | verboseUsage = "Use verbose output" 14 | ) 15 | 16 | type cmdConfig struct { 17 | ConfigFile string 18 | Verbose bool 19 | } 20 | 21 | func main() { 22 | cmdConfig := parseCmd() 23 | 24 | logger := internal.NewLogger(cmdConfig.Verbose) 25 | 26 | config, err := internal.LoadConfig(cmdConfig.ConfigFile) 27 | if err != nil { 28 | logger.Error(err) 29 | } 30 | 31 | cache, err := internal.NewCache(config.IPCache, time.Second*time.Duration(config.IPCacheTimeout)) 32 | if err != nil { 33 | logger.Error(err) 34 | } 35 | 36 | configurator := internal.NewDNSConfigurator(config, cache, logger) 37 | configurator.Configure() 38 | } 39 | 40 | func parseCmd() *cmdConfig { 41 | cmdConfig := &cmdConfig{} 42 | flag.StringVar(&cmdConfig.ConfigFile, "config", defaultConfigFile, configUsage) 43 | flag.StringVar(&cmdConfig.ConfigFile, "c", defaultConfigFile, configUsage+" (shorthand)") 44 | 45 | flag.BoolVar(&cmdConfig.Verbose, "verbose", false, verboseUsage) 46 | flag.BoolVar(&cmdConfig.Verbose, "v", false, verboseUsage+" (shorthand)") 47 | 48 | flag.Parse() 49 | 50 | return cmdConfig 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish the Docker image 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Docker Buildx 23 | id: buildx 24 | uses: docker/setup-buildx-action@v1 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 36 | with: 37 | images: | 38 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 42 | with: 43 | context: . 44 | platforms: linux/amd64,linux/arm/v7 45 | file: ./build/package/Dockerfile 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /cmd/dyndns-netcup-docker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/Hentra/dyndns-netcup-go/internal" 10 | ) 11 | 12 | const ( 13 | configFileLocation = "/config.yml" 14 | ipCacheLocation = "/ipcache" 15 | defaultInterval = time.Minute 16 | intervalEnv = "INTERVAL" 17 | ) 18 | 19 | func main() { 20 | interval, err := parseEnv() 21 | if err != nil { 22 | log.Fatal("Could not parse interval: ", err) 23 | } 24 | 25 | logger := internal.NewLogger(true) 26 | 27 | config, err := internal.LoadConfig(configFileLocation) 28 | if err != nil { 29 | logger.Error("Error loading config file. Make sure a config file is mounted to ", configFileLocation, ":", err) 30 | } 31 | 32 | config.IPCache = ipCacheLocation 33 | 34 | cache, err := internal.NewCache(config.IPCache, time.Second*time.Duration(config.IPCacheTimeout)) 35 | if err != nil { 36 | logger.Error(err) 37 | } 38 | 39 | configurator := internal.NewDNSConfigurator(config, cache, logger) 40 | for { 41 | logger.Info("configure DNS records") 42 | configurator.Configure() 43 | time.Sleep(interval) 44 | } 45 | } 46 | 47 | func parseEnv() (time.Duration, error) { 48 | intervalTime := defaultInterval 49 | if interval, exists := os.LookupEnv(intervalEnv); exists { 50 | intervalSeconds, err := strconv.Atoi(interval) 51 | if err != nil { 52 | return 0, err 53 | } 54 | intervalTime = time.Duration(intervalSeconds) * time.Second 55 | } 56 | 57 | return intervalTime, nil 58 | } 59 | -------------------------------------------------------------------------------- /config/example.yml: -------------------------------------------------------------------------------- 1 | CUSTOMERNR: 12345 2 | APIKEY: 'yourapikey' 3 | APIPASSWORD: 'yourapipassword' 4 | 5 | # Location of the cache file. Leave empty for default location. 6 | # The default location is picked according to your OS. For example 7 | # on Unix sytems it will use $XDG_CACHE_HOME or $HOME/.cache. 8 | IP-CACHE: '/home/user/.cache/dyndns.cache' 9 | 10 | # Time in seconds on how long to wait until rechecking the netcup DNS records. 11 | # If the cache file hasn't been touched for the specified amount of seconds the program 12 | # will refetch the DNS records and compare the ip addresses. 13 | # 14 | # To disable the cache set the value to 0. 15 | IP-CACHE-TIMEOUT: 3600 16 | 17 | DOMAINS: 18 | - NAME: 'example.de' # Your domain name without any subdomains. 19 | IPV6: true # Whether the 'AAAA' entries of this host should be 20 | # updated with the IPv6 address or not. 21 | IPV4: true # Whether the 'A' entries of this host should be 22 | # updated with the IPv4 address or not. This option defaults 23 | # to true when not present. 24 | TTL: 300 # Time to live for this zone. Around 300 is good for dyndns. 25 | HOSTS: # Every host that should get your public ip 26 | - '@' 27 | - '*' 28 | - 'cool.subdomain' # You could also specify subdomains longer than this. 29 | 30 | - NAME: 'example.com' 31 | IPV6: false 32 | IPV4: true 33 | TTL: 350 34 | HOSTS: 35 | - '@' 36 | 37 | # You can add how many domains as you like. Keep in mind that more domains also 38 | # means more requests -> more time. 39 | -------------------------------------------------------------------------------- /internal/config.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "io/ioutil" 5 | 6 | "gopkg.in/yaml.v2" 7 | ) 8 | 9 | // Config represents a config. 10 | type Config struct { 11 | CustomerNumber int `yaml:"CUSTOMERNR"` 12 | APIKey string `yaml:"APIKEY"` 13 | APIPassword string `yaml:"APIPASSWORD"` 14 | IPCache string `yaml:"IP-CACHE"` 15 | IPCacheTimeout int `yaml:"IP-CACHE-TIMEOUT"` 16 | Domains []Domain `yaml:"DOMAINS"` 17 | } 18 | 19 | // Domain represents a domain. 20 | type Domain struct { 21 | Name string `yaml:"NAME"` 22 | IPv6 bool `yaml:"IPV6"` 23 | IPv4 bool `yaml:"IPV4"` 24 | TTL int `yaml:"TTL"` 25 | Hosts []string `yaml:"HOSTS"` 26 | } 27 | 28 | // LoadConfig returns a config loaded from a specified location. It will 29 | // return an error if there is no file in the specified location or it is 30 | // unable to read it. 31 | func LoadConfig(filename string) (*Config, error) { 32 | yamlFile, err := ioutil.ReadFile(filename) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var config Config 38 | err = yaml.Unmarshal(yamlFile, &config) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &config, nil 44 | } 45 | 46 | // UnmarshalYAML is implemented to override the default value of 47 | // the IPv4 field of a Domain with true. 48 | func (d *Domain) UnmarshalYAML(unmarshal func(interface{}) error) error { 49 | type rawDomain Domain 50 | raw := rawDomain{ 51 | IPv4: true, 52 | } 53 | if err := unmarshal(&raw); err != nil { 54 | return err 55 | } 56 | 57 | *d = Domain(raw) 58 | return nil 59 | } 60 | 61 | // CacheEnabled returns whether the cache is enabled in the 62 | // configuration. 63 | func (c *Config) CacheEnabled() bool { 64 | return c.IPCacheTimeout > 0 65 | } 66 | 67 | // IPv6Enabled returns true if at least one domain needs the AAAA 68 | // record configured. 69 | func (c *Config) IPv6Enabled() bool { 70 | for _, domain := range c.Domains { 71 | if domain.IPv6 { 72 | return true 73 | } 74 | } 75 | 76 | return false 77 | } 78 | 79 | // IPv4Enabled returns true if at least one domain needs the A 80 | // record configured. 81 | func (c *Config) IPv4Enabled() bool { 82 | for _, domain := range c.Domains { 83 | if domain.IPv4 { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | } 90 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 4 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 5 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= 6 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 7 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 8 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 9 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 10 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 11 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 12 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 13 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 14 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 15 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 16 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 19 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 20 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 21 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 22 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 23 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 24 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 25 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 26 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 29 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 30 | -------------------------------------------------------------------------------- /pkg/netcup/response.go: -------------------------------------------------------------------------------- 1 | package netcup 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Response represents a response from the netcup api. 8 | type Response struct { 9 | ServerRequestID string `json:"serverrequestid"` 10 | ClientRequestID string `json:"clientrequestid"` 11 | Action string `json:"action"` 12 | Status string `json:"status"` 13 | StatusCode int `json:"statuscode"` 14 | ShortMessage string `json:"shortmessage"` 15 | LongMessage string `json:"longmessage"` 16 | ResponseData json.RawMessage `json:"responsedata"` 17 | } 18 | 19 | // LoginResponse represents the response from the netcup api which is unique 20 | // to the LoginRequest. 21 | type LoginResponse struct { 22 | APISessionid string `json:"apisessionid"` 23 | } 24 | 25 | // DNSZone represents a dns zone. 26 | type DNSZone struct { 27 | DomainName string `json:"name"` 28 | TTL string `json:"ttl"` 29 | Serial string `json:"serial"` 30 | Refresh string `json:"refresh"` 31 | Retry string `json:"retry"` 32 | Expire string `json:"expire"` 33 | DNSSecStatus bool `json:"dnssecstatus"` 34 | } 35 | 36 | // DNSRecord represents a dns record. 37 | type DNSRecord struct { 38 | ID string `json:"id"` 39 | Hostname string `json:"hostname"` 40 | Type string `json:"type"` 41 | Priority string `json:"priority"` 42 | Destination string `json:"destination"` 43 | DeleteRecord bool `json:"deleterecord"` 44 | State string `json:"state"` 45 | } 46 | 47 | // DNSRecordSet represents a dns record set. 48 | type DNSRecordSet struct { 49 | DNSRecords []DNSRecord `json:"dnsrecords"` 50 | } 51 | 52 | // NewDNSRecordSet return a new DNSRecordSet containing specified DNSRecords. 53 | func NewDNSRecordSet(records []DNSRecord) *DNSRecordSet { 54 | return &DNSRecordSet{ 55 | DNSRecords: records, 56 | } 57 | } 58 | 59 | // GetRecordOccurences returns the amount of times a specified hostname with a dnstype 60 | // occures in the DNSRecordSet. 61 | func (r *DNSRecordSet) GetRecordOccurences(hostname, dnstype string) int { 62 | result := 0 63 | for _, record := range r.DNSRecords { 64 | if record.Hostname == hostname && record.Type == dnstype { 65 | result++ 66 | } 67 | } 68 | return result 69 | } 70 | 71 | // GetRecord returns DNSRecord that matches both the name and dnstype specified or nil 72 | // if its not inside the DNSRecordSet. 73 | func (r *DNSRecordSet) GetRecord(name, dnstype string) *DNSRecord { 74 | for _, record := range r.DNSRecords { 75 | if record.Hostname == name && record.Type == dnstype { 76 | return &record 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | // NewDNSRecord returns a new DNSRecord with specified hostname, dnstype and destination. 83 | func NewDNSRecord(hostname, dnstype, destination string) *DNSRecord { 84 | return &DNSRecord{ 85 | Hostname: hostname, 86 | Type: dnstype, 87 | Destination: destination, 88 | } 89 | } 90 | 91 | func (r *Response) isSuccess() bool { 92 | return r.Status == "success" 93 | } 94 | 95 | func (r *Response) getFormattedError() string { 96 | return "netcup: " + r.ShortMessage + " Reason: " + r.LongMessage 97 | } 98 | 99 | func (r *Response) getFormattedStatus() string { 100 | return "netcup: [" + r.Status + "] " + r.LongMessage 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DYNDNS NETCUP GO 2 | ![Build](https://github.com/Hentra/dyndns-netcup-go/workflows/Build/badge.svg?branch=master) 3 | [![Issues](https://img.shields.io/github/issues/Hentra/dyndns-netcup-go)](https://github.com/Hentra/dyndns-netcup-go/issues) 4 | [![Release](https://img.shields.io/github/release/Hentra/dyndns-netcup-go?include_prereleases)](https://github.com/Hentra/dyndns-netcup-go/releases) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/Hentra/dyndns-netcup-go)](https://goreportcard.com/report/github.com/Hentra/dyndns-netcup-go) 6 | 7 | Dyndns client for the netcup DNS API written in go. Not 8 | related to netcup GmbH. It is **heavily** inspired by 9 | [this](https://github.com/stecklars/dynamic-dns-netcup-api) 10 | project which might be also a good solution for your 11 | dynamic DNS needs. 12 | 13 | ## Table of Contents 14 | 15 | 16 | * [Features](#features) 17 | * [Installation](#installation) 18 | * [Docker](#docker) 19 | * [Manual](#manual) 20 | * [From source](#from-source) 21 | * [Usage](#usage) 22 | * [Prequisites](#prequisites) 23 | * [Run dyndns-netcup-go](#run-dyndns-netcup-go) 24 | * [Commandline flags](#commandline-flags) 25 | * [Cache](#cache) 26 | * [Contributing](#contributing) 27 | 28 | 29 | 30 | ## Features 31 | 32 | * Multi domain support 33 | * Subdomain support 34 | * TTL update support 35 | * Creation of a DNS record if it doesn't already exist 36 | * Multi host support (nice when you need to update both `@` and `*`) 37 | * IPv6 support 38 | 39 | If you need additional features please open up an 40 | [Issue](https://github.com/Hentra/dyndns-netcup-go/issues). 41 | 42 | ## Installation 43 | 44 | ### Docker 45 | 46 | docker run -d \ 47 | -v $(pwd)/config.yml:/config.yml \ 48 | -e INTERVAL=300 \ 49 | ghcr.io/hentra/dyndns-netcup-go 50 | 51 | The environment variable `INTERVAL` defines the interval of DNS updates in 52 | seconds. 53 | 54 | ### Manual 55 | 1. Download the lastest [binary](https://github.com/Hentra/dyndns-netcup-go/releases) for your OS 56 | 2. `cd` to the file you downloaded and unzip 57 | 3. Put `dyndns-netcup-go` somewhere in your path 58 | 59 | ### From source 60 | First, install [Go](https://golang.org/doc/install) as 61 | recommended. After that run following commands: 62 | 63 | git clone https://github.com/Hentra/dyndns-netcup-go.git 64 | cd dyndns-netcup-go 65 | go install 66 | 67 | This will create a binary named `dyndns-netcup-go` and install it to your go 68 | binary home. Make sure your `GOPATH` environment variable is set. 69 | 70 | Refer to [Usage](#usage) for further information. 71 | 72 | ## Usage 73 | 74 | ### Prequisites 75 | 76 | * You need to have a netcup account and a domain, obviously. 77 | * Then you need an apikey and apipassword. 78 | [Here](https://www.netcup-wiki.de/wiki/CCP_API#Authentifizierung) is a 79 | description (in German) on how you get those. 80 | 81 | ### Run dyndns-netcup-go 82 | 1. Move/rename the [example configuration](./config/example.yml) `config/example.yml` 83 | to `config.yml` and fill out all the fields. There are some comments in the file for further information. 84 | 2. Run `dyndns-netcup-go -v` in the **same** directory as your configuration file and it will 85 | configure your DNS Records. You can specify the location of the 86 | configuration file with the `-c` or `-config` flag if you don't want to run 87 | it in the same directory. To disable the output for information remove the `-v` flag. You will 88 | still get the output from errors. 89 | 90 | It might be necessary to run this program every few minutes. That interval 91 | depends on how you configured your TTL. 92 | 93 | #### Commandline flags 94 | For a list of all available command line flags run `dyndns-netcup-go -h`. 95 | 96 | ### Cache 97 | Without the cache the application would lookup its ip addresses and fetch the DNS 98 | records from netcup. After that it will compare the specified hosts in the DNS 99 | records with the current ip addresses and update if necessary. 100 | 101 | As reported in [this issue](https://github.com/Hentra/dyndns-netcup-go/issues/1) 102 | it would be also possible to store the ip addresses between two runs of the 103 | application and only fetch DNS records from netcup when they differ. 104 | 105 | To enable the cache configure the two variables `IP-CACHE` and 106 | `IP-CACHE-LOCATION` as according to the comments in `example.yml`. 107 | 108 | ## Contributing 109 | For any feature requests and or bugs open up an 110 | [Issue](https://github.com/Hentra/dyndns-netcup-go/issues). Feel free to also 111 | add a pull request and I will have a look on it. 112 | 113 | -------------------------------------------------------------------------------- /internal/cache.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | "os" 7 | "time" 8 | ) 9 | 10 | const ( 11 | defaultDir string = "/dyndns-netcup-go" 12 | defaultIPCache string = "ip.cache" 13 | ) 14 | 15 | // Cache represents a cache for storing CacheEntries. 16 | type Cache struct { 17 | location string 18 | timeout time.Duration 19 | changes bool 20 | entries []CacheEntry 21 | } 22 | 23 | // CacheEntry represents a cache entry. 24 | type CacheEntry struct { 25 | host string 26 | ipv4 string 27 | ipv6 string 28 | } 29 | 30 | // NewCache returns a new cache struct with a specified location and timeout. When 31 | // the location is an empty string it will set the cache location to the user cache 32 | // dir. 33 | func NewCache(location string, timeout time.Duration) (*Cache, error) { 34 | if location == "" { 35 | var err error 36 | location, err = os.UserCacheDir() 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | location += defaultDir 42 | 43 | if _, err := os.Stat(location); os.IsNotExist(err) { 44 | err = os.MkdirAll(location, 0700) 45 | if err != nil { 46 | return nil, err 47 | } 48 | } 49 | 50 | location += "/" + defaultIPCache 51 | } 52 | 53 | return &Cache{location, timeout, false, nil}, nil 54 | } 55 | 56 | // Load loads the cache from its location. When there is no file at the 57 | // cache location it will do nothing. If the last modification to the file 58 | // was made before the cache timeout it will ignore the file content. 59 | func (c *Cache) Load() error { 60 | csvfile, err := os.Open(c.location) 61 | if err != nil { 62 | if os.IsNotExist(err) { 63 | return nil 64 | } 65 | return err 66 | } 67 | 68 | fileinfo, err := csvfile.Stat() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if time.Since(fileinfo.ModTime()) > c.timeout { 74 | return nil 75 | } 76 | 77 | r := csv.NewReader(csvfile) 78 | 79 | for { 80 | record, err := r.Read() 81 | if err == io.EOF { 82 | break 83 | } 84 | if err != nil { 85 | return err 86 | } 87 | 88 | entry := CacheEntry{ 89 | host: record[0], 90 | ipv4: record[1], 91 | ipv6: record[2], 92 | } 93 | 94 | c.entries = append(c.entries, entry) 95 | } 96 | 97 | return csvfile.Close() 98 | } 99 | 100 | // SetIPv4 sets the ipv4 address for a specified domain and host to 101 | // a specified ipv4 address. 102 | func (c *Cache) SetIPv4(domain, host, ipv4 string) { 103 | entry := c.getEntry(domain, host) 104 | if entry == nil { 105 | newEntry := CacheEntry{ 106 | host: host + "." + domain, 107 | ipv4: ipv4, 108 | ipv6: "", 109 | } 110 | 111 | c.entries = append(c.entries, newEntry) 112 | } else { 113 | entry.ipv4 = ipv4 114 | } 115 | 116 | c.changes = true 117 | } 118 | 119 | // SetIPv6 sets the ipv6 address for a specified domain and host to 120 | // a specified ipv6 address. 121 | func (c *Cache) SetIPv6(domain, host, ipv6 string) { 122 | entry := c.getEntry(domain, host) 123 | if entry == nil { 124 | newEntry := CacheEntry{ 125 | host: host + "." + domain, 126 | ipv4: "", 127 | ipv6: ipv6, 128 | } 129 | 130 | c.entries = append(c.entries, newEntry) 131 | } else { 132 | entry.ipv6 = ipv6 133 | } 134 | 135 | c.changes = true 136 | } 137 | 138 | // GetIPv4 returns the ipv4 address of a specified domain and host. If 139 | // this ipv4 address is not present in the cache it will return an empty string. 140 | func (c *Cache) GetIPv4(domain, host string) string { 141 | entry := c.getEntry(domain, host) 142 | if entry == nil { 143 | return "" 144 | } 145 | 146 | return entry.ipv4 147 | } 148 | 149 | // GetIPv6 returns the ipv6 address of a specified domain and host. If 150 | // this ipv6 address is not present in the cache it will return an empty string. 151 | func (c *Cache) GetIPv6(domain, host string) string { 152 | entry := c.getEntry(domain, host) 153 | if entry == nil { 154 | return "" 155 | } 156 | 157 | return entry.ipv6 158 | } 159 | 160 | func (c *Cache) getEntry(domain, host string) *CacheEntry { 161 | for i, entry := range c.entries { 162 | if entry.host == (host + "." + domain) { 163 | return &c.entries[i] 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // Store stores the cache to its location on the disk. If the 171 | // cache location does not exist it will create the necessary file. 172 | func (c *Cache) Store() error { 173 | if !c.changes { 174 | return nil 175 | } 176 | 177 | csvfile, err := os.Create(c.location) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | writer := csv.NewWriter(csvfile) 183 | defer writer.Flush() 184 | 185 | for _, entry := range c.entries { 186 | err = writer.Write(entry.toArray()) 187 | if err != nil { 188 | return err 189 | } 190 | } 191 | 192 | return nil 193 | } 194 | 195 | func (e *CacheEntry) toArray() []string { 196 | return []string{e.host, e.ipv4, e.ipv6} 197 | } 198 | -------------------------------------------------------------------------------- /pkg/netcup/client.go: -------------------------------------------------------------------------------- 1 | package netcup 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | ) 12 | 13 | const ( 14 | url = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" 15 | ) 16 | 17 | var ( 18 | // ErrNoAPISessionid indicates that there is no session id available. This means that 19 | // you are probably not logged in. 20 | ErrNoAPISessionid = errors.New("netcup: There is no ApiSessionId. Are you logged in?") 21 | 22 | verbose = false 23 | ) 24 | 25 | // Client represents a client to the netcup api. 26 | type Client struct { 27 | client *http.Client 28 | Customernumber int 29 | APIKey string 30 | APIPassword string 31 | APISessionid string 32 | } 33 | 34 | // NewClient returns a new client by customernumber, apikey and apipassword 35 | func NewClient(customernumber int, apikey, apipassword string) *Client { 36 | return &Client{ 37 | Customernumber: customernumber, 38 | APIKey: apikey, 39 | APIPassword: apipassword, 40 | client: http.DefaultClient, 41 | } 42 | } 43 | 44 | func (c *Client) do(req *Request) (*Response, error) { 45 | b, err := json.Marshal(req) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | resp, err := c.client.Post(url, "application/json", bytes.NewBuffer(b)) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer resp.Body.Close() 55 | 56 | body, err := ioutil.ReadAll(resp.Body) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | var response Response 62 | err = json.Unmarshal(body, &response) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if !response.isSuccess() { 68 | return nil, errors.New(response.getFormattedError()) 69 | } 70 | 71 | logInfo(response.getFormattedStatus()) 72 | 73 | return &response, nil 74 | } 75 | 76 | // Login logs the client in the netcup api. This method should be issued before 77 | // any other method. 78 | func (c *Client) Login() error { 79 | var params = NewParams() 80 | params.AddParam("apikey", c.APIKey) 81 | params.AddParam("apipassword", c.APIPassword) 82 | params.AddParam("customernumber", strconv.Itoa(c.Customernumber)) 83 | 84 | request := NewRequest("login", ¶ms) 85 | 86 | response, err := c.do(request) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | var loginResponse LoginResponse 92 | err = json.Unmarshal(response.ResponseData, &loginResponse) 93 | if err != nil { 94 | return err 95 | } else if loginResponse.APISessionid == "" { 96 | return errors.New("netcup: empty sessionid supplied") 97 | } else { 98 | c.APISessionid = loginResponse.APISessionid 99 | } 100 | 101 | return nil 102 | } 103 | 104 | // InfoDNSZone return the DNSZone for a specified domain 105 | func (c *Client) InfoDNSZone(domainname string) (*DNSZone, error) { 106 | params, err := c.basicAuthParams(domainname) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | request := NewRequest("infoDnsZone", params) 112 | 113 | response, err := c.do(request) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | var dnsZone DNSZone 119 | err = json.Unmarshal(response.ResponseData, &dnsZone) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | return &dnsZone, nil 125 | } 126 | 127 | // InfoDNSRecords returns a DNSRecordSet for a specified domain 128 | func (c *Client) InfoDNSRecords(domainname string) (*DNSRecordSet, error) { 129 | params, err := c.basicAuthParams(domainname) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | request := NewRequest("infoDnsRecords", params) 135 | 136 | response, err := c.do(request) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | var dnsRecordSet DNSRecordSet 142 | err = json.Unmarshal(response.ResponseData, &dnsRecordSet) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | return &dnsRecordSet, nil 148 | } 149 | 150 | // UpdateDNSZone updates the specified domain with a specified DNSZone 151 | func (c *Client) UpdateDNSZone(domainname string, dnszone *DNSZone) error { 152 | params, err := c.basicAuthParams(domainname) 153 | if err != nil { 154 | return err 155 | } 156 | params.AddParam("dnszone", dnszone) 157 | request := NewRequest("updateDnsZone", params) 158 | 159 | _, err = c.do(request) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | return nil 165 | } 166 | 167 | // UpdateDNSRecords updates the specified domain with a specified DNSRecordSet 168 | func (c *Client) UpdateDNSRecords(domainname string, dnsRecordSet *DNSRecordSet) error { 169 | params, err := c.basicAuthParams(domainname) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | params.AddParam("dnsrecordset", dnsRecordSet) 175 | request := NewRequest("updateDnsRecords", params) 176 | 177 | _, err = c.do(request) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | return nil 183 | } 184 | 185 | func (c *Client) basicAuthParams(domainname string) (*Params, error) { 186 | if c.APISessionid == "" { 187 | return nil, ErrNoAPISessionid 188 | } 189 | 190 | params := NewParams() 191 | params.AddParam("apikey", c.APIKey) 192 | params.AddParam("apisessionid", c.APISessionid) 193 | params.AddParam("customernumber", strconv.Itoa(c.Customernumber)) 194 | params.AddParam("domainname", domainname) 195 | 196 | return ¶ms, nil 197 | } 198 | 199 | // SetVerbose sets the verboseness of the output. If set to true the response to every 200 | // request will be send to stdout. 201 | func SetVerbose(isVerbose bool) { 202 | verbose = isVerbose 203 | } 204 | 205 | func logInfo(msg string, v ...interface{}) { 206 | if verbose { 207 | log.Printf(msg, v...) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /internal/dns_configurator.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/Hentra/dyndns-netcup-go/pkg/netcup" 7 | ) 8 | 9 | // DNSConfiguratorService represents a service that will update the 10 | // DNS records for a given netcup account 11 | type DNSConfiguratorService struct { 12 | config *Config 13 | client *netcup.Client 14 | cache *Cache 15 | logger *Logger 16 | } 17 | 18 | // NewDNSConfigurator returns a DNSConfiguratorService by given config, cache and logger 19 | func NewDNSConfigurator(config *Config, cache *Cache, logger *Logger) *DNSConfiguratorService { 20 | return &DNSConfiguratorService{ 21 | config: config, 22 | cache: cache, 23 | logger: logger, 24 | } 25 | } 26 | 27 | // Configure will configure the DNS Zones and Records in a netcup account as specified by 28 | // the config 29 | func (dnsc *DNSConfiguratorService) Configure() { 30 | dnsc.login() 31 | 32 | ipAddresses, err := GetAddrInfo(dnsc.config.IPv4Enabled(), dnsc.config.IPv6Enabled()) 33 | if err != nil { 34 | dnsc.logger.Error(err) 35 | } 36 | 37 | dnsc.configureDomains(ipAddresses.IPv4, ipAddresses.IPv6) 38 | 39 | if dnsc.config.CacheEnabled() { 40 | err := dnsc.cache.Store() 41 | if err != nil { 42 | dnsc.logger.Error(err) 43 | } 44 | } 45 | } 46 | 47 | func (dnsc *DNSConfiguratorService) login() { 48 | dnsc.client = netcup.NewClient(dnsc.config.CustomerNumber, dnsc.config.APIKey, dnsc.config.APIPassword) 49 | err := dnsc.client.Login() 50 | if err != nil { 51 | dnsc.logger.Error(err) 52 | } 53 | } 54 | 55 | func (dnsc *DNSConfiguratorService) configureDomains(ipv4, ipv6 string) { 56 | for _, domain := range dnsc.config.Domains { 57 | if dnsc.needsUpdate(domain, ipv4, ipv6) { 58 | dnsc.configureZone(domain) 59 | dnsc.configureRecords(domain, ipv4, ipv6) 60 | } 61 | } 62 | 63 | } 64 | 65 | func (dnsc *DNSConfiguratorService) needsUpdate(domain Domain, ipv4, ipv6 string) bool { 66 | if dnsc.cache == nil { 67 | return true 68 | } 69 | 70 | update := false 71 | 72 | for _, host := range domain.Hosts { 73 | if domain.IPv4 { 74 | hostIPv4 := dnsc.cache.GetIPv4(domain.Name, host) 75 | if hostIPv4 == "" || hostIPv4 != ipv4 { 76 | dnsc.cache.SetIPv4(domain.Name, host, ipv4) 77 | update = true 78 | } 79 | } 80 | 81 | if domain.IPv6 { 82 | hostIPv6 := dnsc.cache.GetIPv6(domain.Name, host) 83 | if hostIPv6 == "" || hostIPv6 != ipv6 { 84 | dnsc.cache.SetIPv6(domain.Name, host, ipv6) 85 | update = true 86 | } 87 | } 88 | 89 | if !update { 90 | dnsc.logger.Info("Host %s is in ipCache and needs no update", host) 91 | } 92 | } 93 | 94 | return update 95 | } 96 | 97 | func (dnsc *DNSConfiguratorService) configureZone(domain Domain) { 98 | dnsc.logger.Info("Loading DNS Zone info for domain %s", domain.Name) 99 | zone, err := dnsc.client.InfoDNSZone(domain.Name) 100 | if err != nil { 101 | dnsc.logger.Error(err) 102 | } 103 | 104 | zoneTTL, err := strconv.Atoi(zone.TTL) 105 | if err != nil { 106 | dnsc.logger.Error(err) 107 | } 108 | 109 | if zoneTTL != domain.TTL { 110 | dnsc.logger.Info("TTL for %s is %d but should be %d. Updating...", domain.Name, zoneTTL, domain.TTL) 111 | 112 | zone.TTL = strconv.Itoa(domain.TTL) 113 | err = dnsc.client.UpdateDNSZone(domain.Name, zone) 114 | if err != nil { 115 | dnsc.logger.Error(err) 116 | } 117 | } 118 | } 119 | 120 | func (dnsc *DNSConfiguratorService) configureRecords(domain Domain, ipv4, ipv6 string) { 121 | dnsc.logger.Info("Loading DNS Records for domain %s", domain.Name) 122 | records, err := dnsc.client.InfoDNSRecords(domain.Name) 123 | if err != nil { 124 | dnsc.logger.Error(err) 125 | } 126 | 127 | var updateRecords []netcup.DNSRecord 128 | for _, host := range domain.Hosts { 129 | if domain.IPv4 { 130 | if records.GetRecordOccurences(host, "A") > 1 { 131 | dnsc.logger.Info("Too many A records for host '%s'. Please specify only Hosts with one corresponding A record", host) 132 | } else { 133 | newRecord, needsUpdate := dnsc.configureARecord(host, ipv4, records) 134 | if needsUpdate { 135 | updateRecords = append(updateRecords, *newRecord) 136 | } 137 | } 138 | } 139 | if domain.IPv6 { 140 | if records.GetRecordOccurences(host, "AAAA") > 1 { 141 | dnsc.logger.Info("Too many AAAA records for host '%s'. Please specify only Hosts with one corresponding AAAA record", host) 142 | } else { 143 | newRecord, needsUpdate := dnsc.configureAAAARecord(host, ipv6, records) 144 | if needsUpdate { 145 | updateRecords = append(updateRecords, *newRecord) 146 | } 147 | } 148 | } 149 | } 150 | 151 | if len(updateRecords) > 0 { 152 | dnsc.logger.Info("Performing update on all queued records") 153 | updateRecordSet := netcup.NewDNSRecordSet(updateRecords) 154 | err = dnsc.client.UpdateDNSRecords(domain.Name, updateRecordSet) 155 | if err != nil { 156 | dnsc.logger.Error(err) 157 | } 158 | } else { 159 | dnsc.logger.Info("No updates queued.") 160 | } 161 | } 162 | 163 | func (dnsc *DNSConfiguratorService) configureARecord(host string, ipv4 string, records *netcup.DNSRecordSet) (*netcup.DNSRecord, bool) { 164 | var result *netcup.DNSRecord 165 | if record := records.GetRecord(host, "A"); record != nil { 166 | dnsc.logger.Info("Found one A record for host '%s'.", host) 167 | if record.Destination != ipv4 { 168 | dnsc.logger.Info("IP address of host '%s' is %s but should be %s. Queue for update...", host, record.Destination, ipv4) 169 | record.Destination = ipv4 170 | result = record 171 | } else { 172 | dnsc.logger.Info("Destination of host '%s' is already public IPv4 %s", host, ipv4) 173 | return nil, false 174 | } 175 | } else { 176 | dnsc.logger.Info("There is no A record for '%s'. Creating and queueing for update", host) 177 | result = netcup.NewDNSRecord(host, "A", ipv4) 178 | } 179 | 180 | return result, true 181 | } 182 | 183 | func (dnsc *DNSConfiguratorService) configureAAAARecord(host string, ipv6 string, records *netcup.DNSRecordSet) (*netcup.DNSRecord, bool) { 184 | var result *netcup.DNSRecord 185 | if record := records.GetRecord(host, "AAAA"); record != nil { 186 | dnsc.logger.Info("Found one AAAA record for host '%s'.", host) 187 | if record.Destination != ipv6 { 188 | dnsc.logger.Info("IP address of host '%s' is %s but should be %s. Queue for update...", host, record.Destination, ipv6) 189 | record.Destination = ipv6 190 | result = record 191 | } else { 192 | dnsc.logger.Info("Destination of host '%s' is already public IPv6 %s", host, ipv6) 193 | return nil, false 194 | } 195 | } else { 196 | dnsc.logger.Info("There is no AAAA record for '%s'. Creating and queueing for update", host) 197 | result = netcup.NewDNSRecord(host, "AAAA", ipv6) 198 | } 199 | 200 | return result, true 201 | } 202 | --------------------------------------------------------------------------------