Initial Commit

This commit is contained in:
Markus Pesch 2021-04-23 16:02:15 +02:00
commit 341e5767be
Signed by: volker.raschek
GPG Key ID: 852BCC170D81A982
21 changed files with 991 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
bin

14
.drone.yml Normal file
View File

@ -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

12
.editorconfig Normal file
View File

@ -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

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
**/Makefile eol=lf

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
bin
pkg/config/*.json
!pkg/config/config.json
dyndns-client
dyndns-client.rpm

27
Dockerfile Normal file
View File

@ -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" ]

13
LICENSE Normal file
View File

@ -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.

146
Makefile Normal file
View File

@ -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}

43
README.md Normal file
View File

@ -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=="
}
}
}
```

12
go.mod Normal file
View File

@ -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
)

26
go.sum Normal file
View File

@ -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=

52
main.go Normal file
View File

@ -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)
}

147
pkg/config/config.go Normal file
View File

@ -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
}

1
pkg/config/config.json Normal file
View File

@ -0,0 +1 @@
{}

335
pkg/daemon/daemon.go Normal file
View File

@ -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]+)*$`)
)

42
pkg/types/config.go Normal file
View File

@ -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
}

7
pkg/types/tsigkey.go Normal file
View File

@ -0,0 +1,7 @@
package types
type TSIGKey struct {
Algorithm string `json:"algorithm"`
Name string `json:"name"`
Secret string `json:"secret"`
}

7
pkg/types/zone.go Normal file
View File

@ -0,0 +1,7 @@
package types
type Zone struct {
DNSServer string `json:"dns-server"`
Name string `json:"name"`
TSIGKeyName string `json:"tsig-key"`
}

72
pkg/updater/updater.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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