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

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