#Build your own dynamic DNS service with GO!

DynDNS with golang

0. List of contents

1. Preface

Dynamic DNS is a handy tool whenever you want access your home environment from the outside world. Most of us have dynamic IP, so the dns server must be updated whenever the dynamic address changes. Somehow I don’t feel comfortable with services such as no-ip.com etc. and that is why I decided to write simple, yet useful dynamic DNS service on my own. It took me ~300 lines of well formatted code. It is not much, isn’t it? Let’s “GO”!

2. Requirements

3. Goal

The goal is to create a DNS server which will be able to respond query/update requests.
So, that I’ll be able to access my home router address using address e.g router.mkaczanowski.com. The implementation doesn’t have to be RFC 2136 compliant. But yet it must work correctly with nsupdate.

4. Why don’t you use e.g no-ip.com?

Huh, you can. There are multiple DynDNS services and I believe they work well. In fact I’ve decided to use my own solution, because it allows me to make some small, yet important modifications (not included in code).

5. Why don’t you use e.g bind server?

I’ve heard a lot about GO lang recently, following the trend I have decided to give it a “GO” 😀 To be honest, I was inspired by this presentation. You may use bind, tinyDNS or whatever you want but remember, go is fun:)

6. Server code

Appending ~300 lines code looks terrible, sorry about that.

How does it work?
At first we look in main() function which:

  1. Parses flags e.g -logfile=/var/log/go-dyndns
  2. Opens and initializes bolt database – this is where records are stored
  3. Creates log file
  4. Starts the UDP server

The function handleDnsRequest() takes care of incoming requests. At this moment only records A and AAAA are supported. The three actions are possible:

  1. Add record (if previously existed, it’ll be overridden)
  2. Delete record (removes record from database)
  3. Query (A or AAAA queries i.e dig @localhost router.mkaczanowski.com. AAAA)

You may also wonder what is TSIG. Transaction SIGnature is a computer networking protocol defined in RFC 2845 which is used to check whether you have right to make an update or not. Mechanism uses shared secret keys and one-way hashing.

getKey() function returns the string which identifies record in database. It consists of the domain in reverse order and record type (A, AAAA). Storing domain backwards is better solution for range scanning.

Other, not mentioned functions are just storing/removing/retrieving records from db. The code is quite short, thanks to well made go-dns library.

package main

import (
	 "errors "
	 "flag "
	 "github.com/boltdb/bolt "
	 "github.com/miekg/dns "
	 "log "
	 "math "
	 "net "
	 "os "
	 "os/signal "
	 "strconv "
	 "strings "
	 "syscall "
	 "time "
)

var (
	tsig     *string
	db_path  *string
	port     *int
	bdb      *bolt.DB
	logfile  *string
	pid_file *string
)

const rr_bucket =  "rr "

func getKey(domain string, rtype uint16) (r string, e error) {
	if n, ok := dns.IsDomainName(domain); ok {
		labels := dns.SplitDomainName(domain)

		// Reverse domain, starting from top-level domain
		// eg.  ".com.mkaczanowski.test "
		var tmp string
		for i := 0; i  < int(math.Floor(float64(n/2))); i++ {
			tmp = labels[i]
			labels[i] = labels[n-1]
			labels[n-1] = tmp
		}

		reverse_domain := strings.Join(labels,  ". ")
		r = strings.Join([]string{reverse_domain, strconv.Itoa(int(rtype))},  "_ ")
	} else {
		e = errors.New( "Invailid domain:  " + domain)
		log.Println(e.Error())
	}

	return r, e
}

func createBucket(bucket string) (err error) {
	err = bdb.Update(func(tx *bolt.Tx) error {
		_, err := tx.CreateBucketIfNotExists([]byte(bucket))
		if err != nil {
			e := errors.New( "Create bucket:  " + bucket)
			log.Println(e.Error())

			return e
		}

		return nil
	})

	return err
}

func deleteRecord(domain string, rtype uint16) (err error) {
	key, _ := getKey(domain, rtype)
	err = bdb.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket([]byte(rr_bucket))
		err := b.Delete([]byte(key))

		if err != nil {
			e := errors.New( "Delete record failed for domain:  " + domain)
			log.Println(e.Error())

			return e
		}

		return nil
	})

	return err
}

func storeRecord(rr dns.RR) (err error) {
	key, _ := getKey(rr.Header().Name, rr.Header().Rrtype)
	err = bdb.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket([]byte(rr_bucket))
		err := b.Put([]byte(key), []byte(rr.String()))

		if err != nil {
			e := errors.New( "Store record failed:  " + rr.String())
			log.Println(e.Error())

			return e
		}

		return nil
	})

	return err
}

func getRecord(domain string, rtype uint16) (rr dns.RR, err error) {
	key, _ := getKey(domain, rtype)
	var v []byte

	err = bdb.View(func(tx *bolt.Tx) error {
		b := tx.Bucket([]byte(rr_bucket))
		v = b.Get([]byte(key))

		if string(v) ==  " " {
			e := errors.New( "Record not found, key:  " + key)
			log.Println(e.Error())

			return e
		}

		return nil
	})

	if err == nil {
		rr, err = dns.NewRR(string(v))
	}

	return rr, err
}

func updateRecord(r dns.RR, q *dns.Question) {
	var (
		rr    dns.RR
		name  string
		rtype uint16
		ttl   uint32
		ip    net.IP
	)

	header := r.Header()
	name = header.Name
	rtype = header.Rrtype
	ttl = header.Ttl

	if _, ok := dns.IsDomainName(name); ok {
		if header.Class == dns.ClassANY && header.Rdlength == 0 { // Delete record
			deleteRecord(name, rtype)
		} else { // Add record
			rheader := dns.RR_Header{
				Name:   name,
				Rrtype: rtype,
				Class:  dns.ClassINET,
				Ttl:    ttl,
			}

			if a, ok := r.(*dns.A); ok {
				rrr, err := getRecord(name, rtype)
				if err == nil {
					rr = rrr.(*dns.A)
				} else {
					rr = new(dns.A)
				}

				ip = a.A
				rr.(*dns.A).Hdr = rheader
				rr.(*dns.A).A = ip
			} else if a, ok := r.(*dns.AAAA); ok {
				rrr, err := getRecord(name, rtype)
				if err == nil {
					rr = rrr.(*dns.AAAA)
				} else {
					rr = new(dns.AAAA)
				}

				ip = a.AAAA
				rr.(*dns.AAAA).Hdr = rheader
				rr.(*dns.AAAA).AAAA = ip
			}

			storeRecord(rr)
		}
	}
}

func parseQuery(m *dns.Msg) {
	var rr dns.RR

	for _, q := range m.Question {
		if read_rr, e := getRecord(q.Name, q.Qtype); e == nil {
			rr = read_rr.(dns.RR)
			if rr.Header().Name == q.Name {
				m.Answer = append(m.Answer, rr)
			}
		}
	}
}

func handleDnsRequest(w dns.ResponseWriter, r *dns.Msg) {
	m := new(dns.Msg)
	m.SetReply(r)
	m.Compress = false

	switch r.Opcode {
	case dns.OpcodeQuery:
		parseQuery(m)

	case dns.OpcodeUpdate:
		for _, question := range r.Question {
			for _, rr := range r.Ns {
				updateRecord(rr, &question)
			}
		}
	}

	if r.IsTsig() != nil {
		if w.TsigStatus() == nil {
			m.SetTsig(r.Extra[len(r.Extra)-1].(*dns.TSIG).Hdr.Name,
				dns.HmacMD5, 300, time.Now().Unix())
		} else {
			log.Println( "Status ", w.TsigStatus().Error())
		}
	}

	w.WriteMsg(m)
}

func serve(name, secret string, port int) {
	server := &dns.Server{Addr:  ": " + strconv.Itoa(port), Net:  "udp "}

	if name !=  " " {
		server.TsigSecret = map[string]string{name: secret}
	}

	err := server.ListenAndServe()
	defer server.Shutdown()

	if err != nil {
		log.Fatalf( "Failed to setup the udp server: %sn ", err.Error())
	}
}

func main() {
	var (
		name   string   // tsig keyname
		secret string   // tsig base64
		fh     *os.File // logfile handle
	)

	// Parse flags
	logfile = flag.String( "logfile ",  " ",  "path to log file ")
	port = flag.Int( "port ", 53,  "server port ")
	tsig = flag.String( "tsig ",  " ",  "use MD5 hmac tsig: keyname:base64 ")
	db_path = flag.String( "db_path ",  "./dyndns.db ",  "location where db will be stored ")
	pid_file = flag.String( "pid ",  "./go-dyndns.pid ",  "pid file location ")

	flag.Parse()

	// Open db
	db, err := bolt.Open(*db_path, 0600,
		&bolt.Options{Timeout: 10 * time.Second})

	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	bdb = db

	// Create dns bucket if doesn't exist
	createBucket(rr_bucket)

	// Attach request handler func
	dns.HandleFunc( ". ", handleDnsRequest)

	// Tsig extract
	if *tsig !=  " " {
		a := strings.SplitN(*tsig,  ": ", 2)
		name, secret = dns.Fqdn(a[0]), a[1]
	}

	// Logger setup
	if *logfile !=  " " {
		if _, err := os.Stat(*logfile); os.IsNotExist(err) {
			if file, err := os.Create(*logfile); err != nil {
				if err != nil {
					log.Panic( "Couldn't create log file:  ", err)
				}

				fh = file
			}
		} else {
			fh, _ = os.OpenFile(*logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
		}
		defer fh.Close()
		log.SetOutput(fh)
	}

	// Pidfile
	file, err := os.OpenFile(*pid_file, os.O_RDWR|os.O_CREATE, 0666)
	if err != nil {
		log.Panic( "Couldn't create pid file:  ", err)
	} else {
		file.Write([]byte(strconv.Itoa(syscall.Getpid())))
		defer file.Close()
	}

	// Start server
	go serve(name, secret, *port)

	sig := make(chan os.Signal)
	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
endless:
	for {
		select {
		case s :=  <-sig:
			log.Printf( "Signal (%d) received, stoppingn ", s)
			break endless
		}
	}
}

7. Add/Remove records via nsupdate

As it was mentioned in Preface, we are going to use nsupdate to push updates on records. It is a part of the bind project, so it can be found in bind-tools or bind-client packages.

Sample request may look as following:

server localhost 53
debug yes
key some_key some_base64_secret_here
zone mkaczanowski.com.
update delete router.mkaczanowski.com. A
update delete router.mkaczanowski.com. AAAA
update add router.mkaczanowski.com. 120 A 88.71.73.131
update add router.mkaczanowski.com. 120 AAAA 2001:41a0:52:a00:0:0:0:212
show
send

Run server:

go run go-dyndns.go -tsig=key:base64_secret_here -port=53

Replace the key and secret with your data and save it to a file “test.txt” and execute: nsupdate test.txt

You may test if your changes are applied:

dig @localhost cluster.mkaczanowski.com. AAAA
dig @localhost cluster.mkaczanowski.com. A

After that you should see updated records.

SCRIPT:

#!/bin/sh
GLOBAL_IP=$(curl -s ipv4.icanhazip.com)
DYNDNS_ADDR= "your_dns_addr "
TSIG_KEY= "some_key "
TSIG_BASE64= "some_secret "

echo  "GLOBAL IP: $GLOBAL_IP "

cat > /tmp/go-dyndns-nsupdate << EOL
server $DYNDNS_ADDR
debug no
key $TSIG_KEY $TSIG_BASE64
zone mkaczanowski.com.
update delete router.mkaczanowski.com. A
update add router.mkaczanowski.com. 120 A $GLOBAL_IP
send
EOL

nsupdate /tmp/go-dyndns-nsupdate

8. Monitor service

It’s time to deploy it on production. Recently I’ve been reading much about supervisord, but in this case I’ll present more oldschool monitoring approach – monit.

At first we should create service (I’m using debian, so it is simple init script)

#! /bin/sh
### BEGIN INIT INFO
# Provides:          go-dyndns
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Go-dyndns init script
### END INIT INFO

BIN=/usr/bin/go-dyndns
PID_FILE=/var/run/go-dynds.pid
LOG_FILE=/var/log/go-dyndns

start() {
    $BIN -tsig=key:base64_secret -port=53 -pid=$PID_FILE -logfile=$LOG_FILE &
}

stop() {
    kill `cat $PID_FILE` 2> /dev/null
}
case  "$1 " in
start)
    echo -n  "Starting Go-dyndns "
    start
    echo  " [ OK ] "
;;

stop)
    echo -n  "Stopping Go-dynds "
    stop
    echo  " [ OK ] "
;;

restart)
    echo -n  "Restarting Go-dynds "
    stop
    start
    echo  " [ OK ] "
;;

*)
echo  "Usage: /etc/init.d/go-dyndns {start|stop|restart} "
exit 1
esac

Next, lets configure monit. Add new configuration /etc/monit/conf.d/go-dyndns.conf:

check process go-dyndns
  with pidfile /var/run/go-dyndns.pid
  start  "/etc/init.d/go-dyndns start "
  stop  "/etc/init.d/go-dyndns stop "
  if failed port 53 proto ssh
    then restart
  if 3 restarts within 5 cycles
    then unmonitor

Reload or restart monit:

/etc/init.d/monit reload

Monit will keep track of go-dyndns process, whenever service is down, monit will try to bring it up.

9. Demo

4 Comments

  1. An important part is the TTL header, mostly DNS queries are cached really hard.
    The best you can do is a ttl setting of one hour or 30 minutes, or better 0 TTL if you have a “good” server or don’t need a good latency.

    DynDNS shines when it comes to low ttl’s. Also you could easily do this with Amazon’s Route 53, if you just update a A Record which has a low TTL.

Leave a Comment.