From 341e5767be9af36f90434a96f385a095bcaf89f1 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Fri, 23 Apr 2021 16:02:15 +0200 Subject: [PATCH] Initial Commit --- .dockerignore | 1 + .drone.yml | 14 ++ .editorconfig | 12 + .gitattributes | 1 + .gitignore | 7 + Dockerfile | 27 +++ LICENSE | 13 ++ Makefile | 146 ++++++++++++ README.md | 43 ++++ go.mod | 12 + go.sum | 26 +++ main.go | 52 +++++ pkg/config/config.go | 147 ++++++++++++ pkg/config/config.json | 1 + pkg/daemon/daemon.go | 335 +++++++++++++++++++++++++++ pkg/types/config.go | 42 ++++ pkg/types/tsigkey.go | 7 + pkg/types/zone.go | 7 + pkg/updater/updater.go | 72 ++++++ systemd/dyndns-client-docker.service | 13 ++ systemd/dyndns-client.service | 13 ++ 21 files changed, 991 insertions(+) create mode 100644 .dockerignore create mode 100644 .drone.yml create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config.json create mode 100644 pkg/daemon/daemon.go create mode 100644 pkg/types/config.go create mode 100644 pkg/types/tsigkey.go create mode 100644 pkg/types/zone.go create mode 100644 pkg/updater/updater.go create mode 100644 systemd/dyndns-client-docker.service create mode 100644 systemd/dyndns-client.service 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