# Build your own dynamic DNS service with GO!

DynDNS with golang

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"!

Requirements

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.

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

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

Server code

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

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

  • Parses flags e.g -logfile=/var/log/go-dyndns
  • Opens and initializes bolt database – this is where records are stored
  • Creates log file
  • 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:

  • Add record (if previously existed, it’ll be overridden)
  • Delete record (removes record from database)
  • 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
        }
    }
}

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.

#!/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

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.

Demo