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
- Go compiler (Installation tutorial)
- Go BoltDB library
- Go DNS library
- nsupdate
- monit
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.