diff --git a/README.md b/README.md index e7da81f..8a706b5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ docker run -it -d \ -e SHARED_SECRET=changeme \ -e ZONE=example.org \ -e RECORD_TTL=3600 \ + -e RECORD_EXPIRY=86400 \ --name=dyndns \ davd/docker-ddns:latest ``` @@ -68,6 +69,7 @@ It provides one single GET request, that is used as follows: http://myhost.mydomain.tld:8080/update?secret=changeme&domain=foo&addr=1.2.3.4 + ### Fields * `secret`: The shared secret set in `envfile` @@ -106,15 +108,19 @@ The handlers will listen on: An example on the ddclient (Linux DDNS client) based Ubiquiti router line: +``` set service dns dynamic interface eth0 service dyndns host-name set service dns dynamic interface eth0 service dyndns login set service dns dynamic interface eth0 service dyndns password set service dns dynamic interface eth0 service dyndns protocol dyndns2 set service dns dynamic interface eth0 service dyndns server +``` Optional if you used this behind an HTTPS reverse proxy like I do: +``` set service dns dynamic interface eth0 service dyndns options ssl=true +``` This also means that DDCLIENT works out of the box and Linux based devices should work. diff --git a/envfile b/envfile index fc51a2d..e93731a 100644 --- a/envfile +++ b/envfile @@ -1,3 +1,4 @@ SHARED_SECRET=changeme ZONE=example.org -RECORD_TTL=3600 \ No newline at end of file +RECORD_TTL=3600 +RECORD_EXPIRY=86400 diff --git a/rest-api/config.go b/rest-api/config.go index 4a591b4..5180cf7 100644 --- a/rest-api/config.go +++ b/rest-api/config.go @@ -12,6 +12,7 @@ type Config struct { Domain string NsupdateBinary string RecordTTL int + RecordExpiry int } func (conf *Config) LoadConfig(path string) { diff --git a/rest-api/ipparser/ipparser.go b/rest-api/ipparser.go similarity index 95% rename from rest-api/ipparser/ipparser.go rename to rest-api/ipparser.go index b93977f..74e78a6 100644 --- a/rest-api/ipparser/ipparser.go +++ b/rest-api/ipparser.go @@ -1,4 +1,4 @@ -package ipparser +package main import ( "net" diff --git a/rest-api/ipparser_test.go b/rest-api/ipparser_test.go index 79f13b6..f4126e1 100644 --- a/rest-api/ipparser_test.go +++ b/rest-api/ipparser_test.go @@ -1,12 +1,11 @@ package main import ( - "dyndns/ipparser" "testing" ) func TestValidIP4ToReturnTrueOnValidAddress(t *testing.T) { - result := ipparser.ValidIP4("1.2.3.4") + result := ValidIP4("1.2.3.4") if result != true { t.Fatalf("Expected ValidIP(1.2.3.4) to be true but got false") @@ -14,7 +13,7 @@ func TestValidIP4ToReturnTrueOnValidAddress(t *testing.T) { } func TestValidIP4ToReturnFalseOnInvalidAddress(t *testing.T) { - result := ipparser.ValidIP4("abcd") + result := ValidIP4("abcd") if result == true { t.Fatalf("Expected ValidIP(abcd) to be false but got true") @@ -22,7 +21,7 @@ func TestValidIP4ToReturnFalseOnInvalidAddress(t *testing.T) { } func TestValidIP4ToReturnFalseOnEmptyAddress(t *testing.T) { - result := ipparser.ValidIP4("") + result := ValidIP4("") if result == true { t.Fatalf("Expected ValidIP() to be false but got true") diff --git a/rest-api/main.go b/rest-api/main.go index d556ad4..d9c44c5 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -10,11 +10,14 @@ import ( "net/http" "os" "os/exec" + "time" + "github.com/boltdb/bolt" "github.com/gorilla/mux" ) var appConfig = &Config{} +var db *bolt.DB = nil func main() { appConfig.LoadConfig("/etc/dyndns.json") @@ -27,6 +30,11 @@ func main() { router.HandleFunc("/v2/update", DynUpdate).Methods("GET") router.HandleFunc("/v3/update", DynUpdate).Methods("GET") + db, _ = bolt.Open("dyndns.db", 0600, nil) + defer db.Close() + + go databaseMaintenance(db) + log.Println(fmt.Sprintf("Serving dyndns REST services on 0.0.0.0:8080...")) log.Fatal(http.ListenAndServe(":8080", router)) } @@ -134,5 +142,92 @@ func UpdateRecord(domain string, ipaddr string, addrType string) string { return err.Error() + ": " + stderr.String() } + /* Create a resource record in the database */ + if err := db.Update(func(tx *bolt.Tx) error { + rr, err := tx.CreateBucketIfNotExists([]byte(domain)) + if err != nil { + return fmt.Errorf("create bucket: %s", err) + } + err = rr.Put([]byte("domain"), []byte(domain)) + err = rr.Put([]byte("zone"), []byte(appConfig.Domain)) + err = rr.Put([]byte("ttl"), []byte(fmt.Sprintf("%v", appConfig.RecordTTL))) + err = rr.Put([]byte("type"), []byte(addrType)) + err = rr.Put([]byte("address"), []byte(ipaddr)) + + t := time.Now() + err = rr.Put([]byte("expiry"), []byte(t.Add(time.Second * time.Duration(appConfig.RecordExpiry)).Format(time.RFC3339))) + err = rr.Put([]byte("created"), []byte(t.Format(time.RFC3339))) + + return nil + }); err != nil { + log.Print(err) + } + return out.String() } + +/* GO func to clean up expired entries */ +func databaseMaintenance(db *bolt.DB) { + cleanupTicker := time.NewTicker(10 * time.Second) + + for { + select { + case <-cleanupTicker.C: + now := []byte(time.Now().Format(time.RFC3339)) + key := []byte("expiry") + + if err := db.View(func(tx *bolt.Tx) error { + /* Iterate through all buckets (each is a resource record) */ + err := tx.ForEach(func(name []byte, b *bolt.Bucket) error { + c := b.Cursor() + + if k, v := c.Seek(key); k != nil && bytes.Equal(k, key) { + // Check for expiry + if bytes.Compare(v, now) < 0 { + if k, v := c.Seek([]byte("type")); k != nil { + log.Printf("Expired RR(%s): '%s'. Deleting.", string(v), string(name)) + go deleteRecord(db, string(name), string(v)) + } + } + } + + return nil + }) + + if err != nil { + log.Print(err) + } + return nil + }); err != nil { + log.Print(err) + } + } + } +} + +/* GO func to delete an entry once expired */ +func deleteRecord(db *bolt.DB, name string, addrType string) { + db.Update(func(tx *bolt.Tx) error { + return tx.DeleteBucket([]byte(name)) + }) + + f, _ := ioutil.TempFile(os.TempDir(), "dyndns_cleanup") + + defer os.Remove(f.Name()) + w := bufio.NewWriter(f) + + w.WriteString(fmt.Sprintf("server %s\n", appConfig.Server)) + w.WriteString(fmt.Sprintf("zone %s\n", appConfig.Zone)) + w.WriteString(fmt.Sprintf("update delete %s.%s %s\n", name, appConfig.Domain, addrType)) + w.WriteString("send\n") + + w.Flush() + f.Close() + + cmd := exec.Command(appConfig.NsupdateBinary, f.Name()) + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + cmd.Run() +} \ No newline at end of file diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 4bb6d37..fc87b00 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -6,8 +6,6 @@ import ( "net" "net/http" "strings" - - "dyndns/ipparser" ) type RequestDataExtractor struct { @@ -51,9 +49,9 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr // kept in the response for compatibility reasons response.Domain = strings.Join(response.Domains, ",") - if ipparser.ValidIP4(response.Address) { + if ValidIP4(response.Address) { response.AddrType = "A" - } else if ipparser.ValidIP6(response.Address) { + } else if ValidIP6(response.Address) { response.AddrType = "AAAA" } else { ip, _, err := net.SplitHostPort(r.RemoteAddr) @@ -66,10 +64,10 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr } // @todo refactor this code to remove duplication - if ipparser.ValidIP4(ip) { + if ValidIP4(ip) { response.AddrType = "A" response.Address = ip - } else if ipparser.ValidIP6(ip) { + } else if ValidIP6(ip) { response.AddrType = "AAAA" response.Address = ip } else { diff --git a/setup.sh b/setup.sh index 2377c44..1a43395 100755 --- a/setup.sh +++ b/setup.sh @@ -3,6 +3,7 @@ [ -z "$SHARED_SECRET" ] && echo "SHARED_SECRET not set" && exit 1; [ -z "$ZONE" ] && echo "ZONE not set" && exit 1; [ -z "$RECORD_TTL" ] && echo "RECORD_TTL not set" && exit 1; +[ -z "$RECORD_EXPIRY" ] && echo "RECORD_EXPIRY not set" && exit 1; if ! grep 'zone "'$ZONE'"' /etc/bind/named.conf > /dev/null then @@ -53,7 +54,8 @@ then "Zone": "${ZONE}.", "Domain": "${ZONE}", "NsupdateBinary": "/usr/bin/nsupdate", - "RecordTTL": ${RECORD_TTL} + "RecordTTL": ${RECORD_TTL}, + "RecordExpiry": ${RECORD_EXPIRY} } EOF fi \ No newline at end of file