commit 341e5767be9af36f90434a96f385a095bcaf89f1 Author: Markus Pesch Date: Fri Apr 23 16:02:15 2021 +0200 Initial Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5e82d7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +bin \ No newline at end of file diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..5beaf32 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,14 @@ +kind: pipeline +type: docker +name: amd64 + +steps: +- name: build-linux-amd64 + image: docker.io/volkerraschek/build-image:latest + commands: + - make + when: + event: + - push + - pull_request + - tag \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b53e68c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5d3e3d0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/Makefile eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50bc5cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin + +pkg/config/*.json +!pkg/config/config.json + +dyndns-client +dyndns-client.rpm \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5796ee8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +ARG BASE_IMAGE +ARG BUILD_IMAGE + +# BUILD +# =========================================== +FROM ${BUILD_IMAGE} AS build +ADD . /workspace + +ARG EXECUTABLE +ARG GONOPROXY +ARG GONOSUMDB +ARG GOPRIVATE +ARG GOPROXY +ARG GOSUMDB +ARG VERSION + +RUN make bin/linux/amd64/${EXECUTABLE} + +# TARGET CONTAINER +# =========================================== +FROM ${BASE_IMAGE} + +ARG EXECUTABLE + +RUN apk add --update bind-tools +COPY --from=build /workspace/bin/linux/amd64/${EXECUTABLE} /usr/bin/${EXECUTABLE} +ENTRYPOINT [ "/usr/bin/dyndns-client" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf58594 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2021 Markus Pesch + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..665b9fe --- /dev/null +++ b/Makefile @@ -0,0 +1,146 @@ +# VERSION +VERSION ?= $(shell git describe --abbrev=0)+hash.$(shell git rev-parse --short HEAD) + + +DESTDIR ?= +PREFIX ?= /usr/local +EXECUTABLE := dyndns-client + +# CONTAINER_RUNTIME +CONTAINER_RUNTIME ?= $(shell which docker) + +# BUILD_IMAGE +BUILD_IMAGE_REGISTRY_HOST := docker.io +BUILD_IMAGE_NAMESPACE := volkerraschek +BUILD_IMAGE_REPOSITORY := build-image +BUILD_IMAGE_VERSION := latest +BUILD_IMAGE_FULLY_QUALIFIED := ${BUILD_IMAGE_REGISTRY_HOST}/${BUILD_IMAGE_NAMESPACE}/${BUILD_IMAGE_REPOSITORY}:${BUILD_IMAGE_VERSION:v%=%} + +# BASE_IMAGE +BASE_IMAGE_REGISTRY_HOST := docker.io +BASE_IMAGE_NAMESPACE := library +BASE_IMAGE_REPOSITORY := alpine +BASE_IMAGE_VERSION := 3.12.0 +BASE_IMAGE_FULLY_QUALIFIED := ${BASE_IMAGE_REGISTRY_HOST}/${BASE_IMAGE_NAMESPACE}/${BASE_IMAGE_REPOSITORY}:${BASE_IMAGE_VERSION:v%=%} + +# CONTAINER_IMAGE +CONTAINER_IMAGE_REGISTRY_HOST := docker.io +CONTAINER_IMAGE_NAMESPACE := volkerraschek +CONTAINER_IMAGE_REPOSITORY := ${EXECUTABLE} +CONTAINER_IMAGE_VERSION := latest +CONTAINER_IMAGE_FULLY_QUALIFIED := ${CONTAINER_IMAGE_REGISTRY_HOST}/${CONTAINER_IMAGE_NAMESPACE}/${CONTAINER_IMAGE_REPOSITORY}:${CONTAINER_IMAGE_VERSION:v%=%} +CONTAINER_IMAGE_UNQUALIFIED := ${CONTAINER_IMAGE_NAMESPACE}/${CONTAINER_IMAGE_REPOSITORY}:${CONTAINER_IMAGE_VERSION:v%=%} + +# BINARIES +# ============================================================================== +${EXECUTABLE}: clean bin/tmp/${EXECUTABLE} + +bin/linux/amd64/$(EXECUTABLE): + CGO_ENABLED=0 \ + GONOPROXY=$(shell go env GONOPROXY) \ + GONOSUMDB=$(shell go env GONOSUMDB) \ + GOPRIVATE=$(shell go env GOPRIVATE) \ + GOPROXY=$(shell go env GOPROXY) \ + GOSUMDB=$(shell go env GOSUMDB) \ + GOOS=linux \ + GOARCH=amd64 \ + go build -ldflags "-X main.version=${VERSION:v%=%}" -o ${@} + +bin/tmp/${EXECUTABLE}: + CGO_ENABLED=0 \ + GONOPROXY=$(shell go env GONOPROXY) \ + GONOSUMDB=$(shell go env GONOSUMDB) \ + GOPRIVATE=$(shell go env GOPRIVATE) \ + GOPROXY=$(shell go env GOPROXY) \ + GOSUMDB=$(shell go env GOSUMDB) \ + go build -ldflags "-X main.version=${VERSION:v%=%}" -o ${@} + +# TEST +# ============================================================================== +PHONY+=test +test: clean bin/tmp/${EXECUTABLE} + go test -v ./pkg/... + +# CLEAN +# ============================================================================== +PHONY+=clean +clean: + rm --force ${EXECUTABLE} || true + rm --force --recursive bin || true + +# CONTAINER IMAGE +# ============================================================================== +container-image/build: + ${CONTAINER_RUNTIME} build \ + --build-arg BASE_IMAGE=${BASE_IMAGE_FULLY_QUALIFIED} \ + --build-arg BUILD_IMAGE=${BUILD_IMAGE_FULLY_QUALIFIED} \ + --build-arg EXECUTABLE=${EXECUTABLE} \ + --build-arg GONOPROXY=$(shell go env GONOPROXY) \ + --build-arg GONOSUMDB=$(shell go env GONOSUMDB) \ + --build-arg GOPRIVATE=$(shell go env GOPRIVATE) \ + --build-arg GOPROXY=$(shell go env GOPROXY) \ + --build-arg GOSUMDB=$(shell go env GOSUMDB) \ + --build-arg VERSION=${VERSION:v%=%} \ + --no-cache \ + --tag ${CONTAINER_IMAGE_FULLY_QUALIFIED} \ + --tag ${CONTAINER_IMAGE_UNQUALIFIED} \ + . + +container-image/push: container-image/build + ${CONTAINER_RUNTIME} push ${CONTAINER_IMAGE_FULLY_QUALIFIED} + +# CONTAINER RUN - TEST +# ============================================================================== +PHONY+=container-run/test +container-run/test: + $(MAKE) container-run COMMAND=${@:container-run/%=%} + +# CONTAINER RUN - CLEAN +# ============================================================================== +PHONY+=container-run/clean +container-run/clean: + $(MAKE) container-run COMMAND=${@:container-run/%=%} + +# CONTAINER RUN - COMMAND +# ============================================================================== +PHONY+=container-run +container-run: + ${CONTAINER_RUNTIME} run \ + --env GONOPROXY=$(shell go env GONOPROXY) \ + --env GONOSUMDB=$(shell go env GONOSUMDB) \ + --env GOPRIVATE=$(shell go env GOPRIVATE) \ + --env GOPROXY=$(shell go env GOPROXY) \ + --env GOSUMDB=$(shell go env GOSUMDB) \ + --env EPOCH=${EPOCH} \ + --env VERSION=${VERSION:v%=%} \ + --env RELEASE=${RELEASE} \ + --rm \ + --volume $(shell pwd):/workspace \ + ${BUILD_IMAGE_FULLY_QUALIFIED} \ + make ${COMMAND} \ + +# UN/INSTALL +# ============================================================================== +PHONY+=install +install: bin/tmp/${EXECUTABLE} + install --directory ${DESTDIR}${PREFIX}/bin + install --mode 755 bin/tmp/${EXECUTABLE} ${DESTDIR}${PREFIX}/bin/${EXECUTABLE} + + install --directory ${DESTDIR}/usr/lib/systemd/system + install --mode 644 systemd/${EXECUTABLE}.service ${DESTDIR}/usr/lib/systemd/system + install --mode 644 systemd/${EXECUTABLE}-docker.service ${DESTDIR}/usr/lib/systemd/system + + install --directory ${DESTDIR}/usr/share/licenses/${EXECUTABLE} + install --mode 644 LICENSE ${DESTDIR}/usr/share/licenses/${EXECUTABLE}/LICENSE + +PHONY+=uninstall +uninstall: + -rm --recursive --force \ + ${DESTDIR}${PREFIX}/bin/${EXECUTABLE} \ + ${DESTDIR}/usr/lib/systemd/system/${EXECUTABLE}.service \ + ${DESTDIR}/usr/lib/systemd/system/${EXECUTABLE}-docker.service \ + ${DESTDIR}/usr/share/licenses/${EXECUTABLE}/LICENSE + +# PHONY +# ============================================================================== +.PHONY: ${PHONY} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f52b40 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# dyndns-client + +[![Build Status](https://drone.cryptic.systems/api/badges/dyndns-client/dyndns-client/status.svg)](https://drone.cryptic.systems/dyndns-client/dyndns-client) + +dyndns-client is a Daemon to listen on interface notifications produced by the linux +kernel of a client machine to update one or more DNS zones. + +## Usage + +To start dyndns-client just run `./dyndns-client`. + +## Configuration + +The program is compiled as standalone binary without third party libraries. If +no configuration file available under `/etc/dyndns-client/config.json`, than +will be the burned in configuration used. If also no configuration be burned +into the source code, that the client returned an error. + +The example below describes a configuration to update RRecords triggerd by the +interface `br0` for the `example.com` zone. To update the zone is a TSIG-Key +required. + +```json +{ + "interfaces": [ + "br0" + ], + "zones": { + "example.com": { + "dns-server": "10.6.231.5", + "name": "example.com", + "tsig-key": "my-key" + } + }, + "tsig-keys": { + "my-key": { + "algorithm": "hmac-sha512", + "name": "my-key", + "secret": "asdasdasdasdjkhjk38hcn38haoĆ¼2390dndaskdTTWA==" + } + } +} +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c5c6632 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module git.cryptic.systems/volker.raschek/dyndns-client + +go 1.16 + +require ( + github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d + github.com/sirupsen/logrus v1.8.1 + github.com/stretchr/testify v1.6.1 // indirect + github.com/vishvananda/netlink v1.1.0 + github.com/vishvananda/netns v0.0.0-20200520041808-52d707b772fe // indirect + golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..17e900f --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/vishvananda/netns v0.0.0-20200520041808-52d707b772fe h1:mjAZxE1nh8yvuwhGHpdDqdhtNu2dgbpk93TwoXuk5so= +github.com/vishvananda/netns v0.0.0-20200520041808-52d707b772fe/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..88fe819 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/config" + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/daemon" +) + +var ( + version string +) + +func main() { + + switch os.Getenv("DYNDNS_CLIENT_LOGGER_LEVEL") { + case "DEBUG", "debug": + log.SetLevel(log.DebugLevel) + case "WARN", "warn": + log.SetLevel(log.WarnLevel) + case "ERROR", "error": + log.SetLevel(log.ErrorLevel) + case "FATAL", "fatal": + log.SetLevel(log.FatalLevel) + case "INFO", "info": + fallthrough + default: + log.SetLevel(log.InfoLevel) + } + + switch os.Getenv("DYNDNS_CLIENT_LOGGER_FORMATTER") { + case "JSON", "json": + log.SetFormatter(&log.JSONFormatter{}) + case "TEXT", "text": + fallthrough + default: + log.SetFormatter(&log.TextFormatter{ + FullTimestamp: true, + }) + } + + log.Infof("version %v", version) + + cnf, err := config.Read("/etc/dyndns-client/config.json") + if err != nil { + log.Fatal(err.Error()) + } + + daemon.Start(cnf) +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..8733099 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + _ "embed" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/types" + log "github.com/sirupsen/logrus" +) + +//go:embed config.json +var defaultConfig string + +// GetDefaultConfiguration returns a default configuration +func GetDefaultConfiguration() (*types.Config, error) { + cnf := new(types.Config) + jsonDecoder := json.NewDecoder(strings.NewReader(defaultConfig)) + + err := jsonDecoder.Decode(cnf) + if err != nil { + return nil, fmt.Errorf("failed to decode default config: %w", err) + } + + defaultInterface, err := getDefaultInterfaceByIP() + if err != nil { + return nil, err + } + cnf.Ifaces = []string{defaultInterface.Name} + + return cnf, nil +} + +// Read config from a file +func Read(cnfFile string) (*types.Config, error) { + + // Load burned in configuration if config not available + if _, err := os.Stat(cnfFile); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(cnfFile), 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + cnf, err := GetDefaultConfiguration() + if err != nil { + return nil, err + } + + err = cnf.Validate() + if err != nil { + return nil, err + } + + log.Infof("use embedded configuration") + + return cnf, nil + } + + f, err := os.Open(cnfFile) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + cnf := new(types.Config) + jsonDecoder := json.NewDecoder(f) + err = jsonDecoder.Decode(cnf) + if err != nil { + return nil, fmt.Errorf("failed to decode json: %w", err) + } + + for _, iface := range cnf.Ifaces { + if _, err := net.InterfaceByName(iface); err != nil { + return nil, fmt.Errorf("unknown interface: %v", iface) + } + } + + err = cnf.Validate() + if err != nil { + return nil, err + } + + log.Infof("use configuration from file %v", cnfFile) + + return cnf, nil +} + +// Write config into a file +func Write(cnf *types.Config, cnfFile string) error { + if _, err := os.Stat(filepath.Dir(cnfFile)); os.IsNotExist(err) { + err := os.MkdirAll(filepath.Dir(cnfFile), 0755) + if err != nil { + return err + } + } + + f, err := os.Create(cnfFile) + if err != nil { + return fmt.Errorf("failed to create file %v: %v", cnfFile, err) + } + defer f.Close() + + jsonEncoder := json.NewEncoder(f) + jsonEncoder.SetIndent("", " ") + err = jsonEncoder.Encode(cnf) + if err != nil { + return fmt.Errorf("failed to encode json: %w", err) + } + return nil +} + +func getDefaultInterfaceByIP() (*net.Interface, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("failed to fet network interfaces from kernel: %w", err) + } + + defaultIP := getOutboundIP() + + for _, iface := range ifaces { + addrs, err := iface.Addrs() + if err != nil { + return nil, fmt.Errorf("failed to list ip addresses for interface %v: %w", iface.Name, err) + } + + for _, addr := range addrs { + addrIP := strings.Split(addr.String(), "/")[0] + if addrIP == defaultIP.String() { + return &iface, nil + } + } + } + + return nil, fmt.Errorf("no interface found fo ip address %v", defaultIP) +} + +func getOutboundIP() net.IP { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + log.Fatal(err) + } + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP +} diff --git a/pkg/config/config.json b/pkg/config/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/pkg/config/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go new file mode 100644 index 0000000..78b718e --- /dev/null +++ b/pkg/daemon/daemon.go @@ -0,0 +1,335 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/types" + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/updater" + "github.com/asaskevich/govalidator" + log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" +) + +func Start(cnf *types.Config) { + addrUpdates := make(chan netlink.AddrUpdate, 1) + done := make(chan struct{}, 1) + err := netlink.AddrSubscribeWithOptions(addrUpdates, done, netlink.AddrSubscribeOptions{ + ListExisting: true, + }) + if err != nil { + log.Fatalf("failed to subscribe netlink notifications from kernel: %v", err.Error()) + } + + interuptChannel := make(chan os.Signal, 1) + signal.Notify(interuptChannel, syscall.SIGINT, syscall.SIGTERM) + + ctx := context.Background() + daemonCtx, cancle := context.WithCancel(ctx) + defer cancle() + + updaters, err := getUpdaterForEachZone(cnf) + if err != nil { + log.Fatalf("%v", err.Error()) + } + + if err := pruneRecords(daemonCtx, updaters, cnf.Zones); err != nil { + log.Fatalf("%v", err.Error()) + } + + for { + interfaces, err := netlink.LinkList() + if err != nil { + log.Fatal("%v", err.Error()) + } + + select { + case update := <-addrUpdates: + + interfaceLogger := log.WithFields(log.Fields{ + "ip": update.LinkAddress.IP.String(), + }) + + // search interface by index + iface, err := searchInterfaceByIndex(update.LinkIndex, interfaces) + if err != nil { + log.Errorf("%v", err.Error()) + continue + } + interfaceLogger = interfaceLogger.WithField("device", iface.Attrs().Name) + + var recordType string + switch { + case govalidator.IsIPv4(strings.TrimRight(update.LinkAddress.IP.String(), "/")): + recordType = "A" + case govalidator.IsIPv6(strings.TrimRight(update.LinkAddress.IP.String(), "/")): + recordType = "AAAA" + default: + interfaceLogger.Error("failed to detect record type") + continue + } + interfaceLogger = interfaceLogger.WithField("rr", recordType) + + interfaceLogger.Debug("receive kernel notification for interface") + + // filter out not configured interfaces + if !matchInterfaces(iface.Attrs().Name, cnf.Ifaces) { + interfaceLogger.Warn("interface is not part of the allowed interface list") + continue + } + + // filter out notification for a bad interface ip address, for example link-local-addresses + if update.LinkAddress.IP.IsLoopback() || strings.HasPrefix(update.LinkAddress.IP.String(), "fe80") { + interfaceLogger.Warn("interface is a loopback device or part of a loopback network") + continue + } + + // decide if trigger a add or delete event + if update.NewAddr { + err = addIPRecords(daemonCtx, interfaceLogger, updaters, cnf.Zones, recordType, update.LinkAddress.IP) + if err != nil { + interfaceLogger.Error(err.Error()) + } + } else { + err = removeIPRecords(daemonCtx, interfaceLogger, updaters, cnf.Zones, recordType, update.LinkAddress.IP) + if err != nil { + interfaceLogger.Error(err.Error()) + } + } + + case killSignal := <-interuptChannel: + log.Debugf("got signal: %v", killSignal) + log.Debugf("daemon was killed by: %v", killSignal) + return + } + } +} + +func getUpdaterForEachZone(config *types.Config) (map[string]updater.Updater, error) { + updaterCollection := make(map[string]updater.Updater) + + for zoneName, zone := range config.Zones { + nsUpdater, err := updater.NewNSUpdate(zone.DNSServer, config.TSIGKeys[zone.TSIGKeyName]) + if err != nil { + return nil, err + } + updaterCollection[zoneName] = nsUpdater + } + + return updaterCollection, nil +} + +func matchInterfaces(iface string, ifaces []string) bool { + for _, i := range ifaces { + if i == iface { + return true + } + } + return false +} + +func searchInterfaceByIndex(index int, interfaces []netlink.Link) (netlink.Link, error) { + for _, iface := range interfaces { + if iface.Attrs().Index == index { + return iface, nil + } + } + return nil, fmt.Errorf("can not find interface by index %v", index) +} + +func addIPRecords(ctx context.Context, logEntry *log.Entry, updaters map[string]updater.Updater, zones map[string]*types.Zone, recordType string, ip net.IP) error { + + var ( + errorChannel = make(chan error, len(zones)) + wg = new(sync.WaitGroup) + ) + + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("failed to get host name from kernel: %w", err) + } + hostname = strings.ToLower(hostname) + + if !verifyHostname(hostname) { + return fmt.Errorf("host name not valid: %w", err) + } + + for zoneName := range zones { + wg.Add(1) + + go func(ctx context.Context, zoneName string, hostname string, recordType string, ip net.IP, wg *sync.WaitGroup) { + + zoneLogger := logEntry.WithFields(log.Fields{ + "zone": zoneName, + "hostname": hostname, + }) + + defer wg.Done() + + pruneRecordCtx, cancle := context.WithTimeout(ctx, time.Second*15) + defer cancle() + + fqdn := fmt.Sprintf("%v.%v", hostname, zoneName) + + err := updaters[zoneName].AddRecord(pruneRecordCtx, fqdn, 60, recordType, ip.String()) + if err != nil { + errorChannel <- fmt.Errorf("failed to remove record type %v for %v: %v", recordType, fqdn, err.Error()) + return + } + + zoneLogger.Info("dns-record successfully updated") + + }(ctx, zoneName, hostname, recordType, ip, wg) + } + + wg.Wait() + close(errorChannel) + + for err := range errorChannel { + if err != nil { + return err + } + } + + return nil +} + +func pruneRecords(ctx context.Context, updaters map[string]updater.Updater, zones map[string]*types.Zone) error { + + var ( + errorChannel = make(chan error, len(zones)) + wg = new(sync.WaitGroup) + ) + + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("failed to get host name from kernel: %w", err) + } + hostname = strings.ToLower(hostname) + + if !verifyHostname(hostname) { + return fmt.Errorf("host name not valid: %w", err) + } + + for zoneName := range zones { + wg.Add(1) + + go func(zoneName string, hostname string, errorChannel chan<- error, wg *sync.WaitGroup) { + defer wg.Done() + + pruneRecordCtx, cancle := context.WithTimeout(ctx, time.Second*15) + defer cancle() + + fqdn := fmt.Sprintf("%v.%v", hostname, zoneName) + + err := updaters[zoneName].PruneRecords(pruneRecordCtx, fqdn) + if err != nil { + errorChannel <- fmt.Errorf("failed to prune %v: %v", fqdn, err) + return + } + }(zoneName, hostname, errorChannel, wg) + } + + wg.Wait() + close(errorChannel) + + for err := range errorChannel { + if err != nil { + return err + } + } + + return nil +} + +func removeIPRecords(ctx context.Context, logEntry *log.Entry, updaters map[string]updater.Updater, zones map[string]*types.Zone, recordType string, ip net.IP) error { + + var ( + errorChannel = make(chan error, len(zones)) + wg = new(sync.WaitGroup) + ) + + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("failed to get host name from kernel: %w", err) + } + hostname = strings.ToLower(hostname) + + if !verifyHostname(hostname) { + return fmt.Errorf("host name not valid: %w", err) + } + + for zoneName := range zones { + wg.Add(1) + + go func(ctx context.Context, zoneName string, hostname string, recordType string, wg *sync.WaitGroup) { + defer wg.Done() + + zoneLogger := logEntry.WithFields(log.Fields{ + "zone": zoneName, + "hostname": hostname, + }) + + pruneRecordCtx, cancle := context.WithTimeout(ctx, time.Second*15) + defer cancle() + + fqdn := fmt.Sprintf("%v.%v", hostname, zoneName) + + err := updaters[zoneName].DeleteRecord(pruneRecordCtx, fqdn, recordType) + if err != nil { + errorChannel <- fmt.Errorf("failed to remove record type %v for %v: %v", recordType, fqdn, err.Error()) + return + } + + zoneLogger.Info("dns-record successfully removed") + + }(ctx, zoneName, hostname, recordType, wg) + } + + wg.Wait() + close(errorChannel) + + for err := range errorChannel { + if err != nil { + return err + } + } + + return nil +} + +// verifyHostname returns a boolean if the hostname id valid. The hostname does +// not contains any dot or local, localhost, localdomain. +func verifyHostname(hostname string) bool { + + if !validHostname.MatchString(hostname) { + return false + } + + hostnames := []string{ + "local", + "localhost", + "localdomain", + "orbisos", + } + + for i := range hostnames { + if hostnames[i] == hostname { + return false + } + } + + return true +} + +var ( + validHostname = regexp.MustCompile(`^[a-zA-Z0-9]+([\-][a-zA-Z0-9]+)*$`) +) diff --git a/pkg/types/config.go b/pkg/types/config.go new file mode 100644 index 0000000..a9ad82a --- /dev/null +++ b/pkg/types/config.go @@ -0,0 +1,42 @@ +package types + +import ( + "fmt" + + "github.com/asaskevich/govalidator" +) + +type Config struct { + Ifaces []string `json:"interfaces"` + Zones map[string]*Zone `json:"zones"` + TSIGKeys map[string]*TSIGKey `json:"tsig-keys"` +} + +func (c *Config) Validate() error { + if len(c.Zones) <= 0 { + return fmt.Errorf("no dns zones configured") + } + + if len(c.Ifaces) <= 0 { + return fmt.Errorf("no interfaces configured") + } + +ZONE_LOOP: + for _, zone := range c.Zones { + if !govalidator.IsIP(zone.DNSServer) && !govalidator.IsDNSName(zone.DNSServer) { + return fmt.Errorf("invalid dns server %v", zone.DNSServer) + } + + if !govalidator.IsDNSName(zone.Name) { + return fmt.Errorf("invalid dns zone name %v", zone.Name) + } + + for _, tsigkey := range c.TSIGKeys { + if tsigkey.Name == zone.TSIGKeyName { + continue ZONE_LOOP + } + } + return fmt.Errorf("no matching tsigkey found for zone %v", zone.Name) + } + return nil +} diff --git a/pkg/types/tsigkey.go b/pkg/types/tsigkey.go new file mode 100644 index 0000000..3eafcdf --- /dev/null +++ b/pkg/types/tsigkey.go @@ -0,0 +1,7 @@ +package types + +type TSIGKey struct { + Algorithm string `json:"algorithm"` + Name string `json:"name"` + Secret string `json:"secret"` +} diff --git a/pkg/types/zone.go b/pkg/types/zone.go new file mode 100644 index 0000000..76e352a --- /dev/null +++ b/pkg/types/zone.go @@ -0,0 +1,7 @@ +package types + +type Zone struct { + DNSServer string `json:"dns-server"` + Name string `json:"name"` + TSIGKeyName string `json:"tsig-key"` +} diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 0000000..e673dd1 --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,72 @@ +package updater + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "git.cryptic.systems/volker.raschek/dyndns-client/pkg/types" +) + +type Updater interface { + AddRecord(ctx context.Context, name string, ttl uint, recordType string, data string) error + DeleteRecord(ctx context.Context, name string, recordType string) error + PruneRecords(ctx context.Context, name string) error +} + +type NSUpdate struct { + server string + tsigKey *types.TSIGKey +} + +func (u *NSUpdate) AddRecord(ctx context.Context, name string, ttl uint, recordType string, data string) error { + nsUpdateCmd := "" + switch recordType { + case "A", "AAAA": + nsUpdateCmd = fmt.Sprintf("update add %v %v IN %v %v", name, ttl, recordType, data) + case "TXT": + nsUpdateCmd = fmt.Sprintf("update add %v %v IN %v \"%v\"", name, ttl, recordType, data) + default: + return fmt.Errorf("RecordType %v not supported", recordType) + } + + return u.execute(ctx, nsUpdateCmd) +} + +func (u *NSUpdate) DeleteRecord(ctx context.Context, name string, recordType string) error { + nsUpdateCmd := fmt.Sprintf("update delete %v IN %v", name, recordType) + return u.execute(ctx, nsUpdateCmd) +} + +func (u *NSUpdate) execute(ctx context.Context, nsUpdateCmd string) error { + body := fmt.Sprintf("server %v\n%v\nsend\nquit", u.server, nsUpdateCmd) + + errBuffer := new(bytes.Buffer) + + cmd := exec.CommandContext(ctx, "nsupdate", "-y", fmt.Sprintf("%v:%v:%v", u.tsigKey.Algorithm, u.tsigKey.Name, u.tsigKey.Secret)) + // cmd.Stdout = os.Stdout + cmd.Stderr = bufio.NewWriter(errBuffer) + cmd.Stdin = strings.NewReader(body) + + err := cmd.Run() + if err != nil { + return fmt.Errorf("nsupdate error %w: %v", err, errBuffer.String()) + } + + return nil +} + +func (u *NSUpdate) PruneRecords(ctx context.Context, name string) error { + nsUpdateCmd := fmt.Sprintf("update delete %v", name) + return u.execute(ctx, nsUpdateCmd) +} + +func NewNSUpdate(server string, tsigKey *types.TSIGKey) (Updater, error) { + return &NSUpdate{ + server: server, + tsigKey: tsigKey, + }, nil +} diff --git a/systemd/dyndns-client-docker.service b/systemd/dyndns-client-docker.service new file mode 100644 index 0000000..4235c0d --- /dev/null +++ b/systemd/dyndns-client-docker.service @@ -0,0 +1,13 @@ +[Unit] +Description=dyndns-client +Requires=docker.service network-online.target time-sync.target +After=docker.service network-online.target time-sync.target + +[Service] +Type=simple +ExecStartPre=/usr/bin/docker pull docker.io/volkerraschek/dyndns-client:latest +ExecStart=/usr/bin/docker run --name=dyndns-client --network=host --privileged --rm --volume /etc/dyndns-client:/etc/dyndns-client docker.io/volkerraschek/dyndns-client:latest +ExecStop=/usr/bin/docker stop dyndns-client + +[Install] +WantedBy=multi-user.target diff --git a/systemd/dyndns-client.service b/systemd/dyndns-client.service new file mode 100644 index 0000000..b011cb4 --- /dev/null +++ b/systemd/dyndns-client.service @@ -0,0 +1,13 @@ +[Unit] +Description=dyndns-client +Requires=network-online.target time-sync.target +After=network-online.target time-sync.target + +[Service] +Type=simple +Environment=DYNDNS_CLIENT_LOGGER_LEVEL=INFO +ExecStart=/usr/bin/dyndns-client +KillSignal=SIGTERM + +[Install] +WantedBy=multi-user.target