From 7e6c18eddb289b5f7c2e0b408a01b9ba275e2a2f Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Sat, 28 Dec 2019 10:49:00 +0000 Subject: [PATCH 1/6] Added handlers to support DynDNS compliant requests --- rest-api/main.go | 33 ++++++++++++++++++ rest-api/request_handler.go | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/rest-api/main.go b/rest-api/main.go index f0ef9e6..3a4f6e2 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -21,11 +21,44 @@ func main() { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/update", Update).Methods("GET") + router.HandleFunc("/nic/update", DynUpdate).Methods("GET") + router.HandleFunc("/v2/update", DynUpdate).Methods("GET") + router.HandleFunc("/v3/update", DynUpdate).Methods("GET") log.Println(fmt.Sprintf("Serving dyndns REST services on 0.0.0.0:8080...")) log.Fatal(http.ListenAndServe(":8080", router)) } +func DynUpdate(w http.ResponseWriter, r *http.Request) { + response := BuildDynResponseFromRequest(r, appConfig) + + if response.Success == false { + if response.Message == "Domain not set" { + w.Write([]byte("notfqdn\n")) + } else { + w.Write([]byte("badauth\n")) + } + return + } + + for _, domain := range response.Domains { + result := UpdateRecord(domain, response.Address, response.AddrType) + + if result != "" { + response.Success = false + response.Message = result + + w.Write([]byte("dnserr\n")) + return + } + } + + response.Success = true + response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", response.AddrType, response.Domain, response.Address) + + w.Write([]byte(fmt.Sprintf("good %s\n", response.Address))) +} + func Update(w http.ResponseWriter, r *http.Request) { response := BuildWebserviceResponseFromRequest(r, appConfig) diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index a8bc6d1..6b58c40 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -81,3 +81,70 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config) Webs return response } + +func BuildDynResponseFromRequest(r *http.Request, appConfig *Config) WebserviceResponse { + response := WebserviceResponse{} + + var sharedSecret string + + vals := r.URL.Query() + _, sharedSecret, ok := r.BasicAuth() + if !ok || sharedSecret == "" { + sharedSecret = vals.Get("password") + } + + response.Domains = strings.Split(vals.Get("hostname"), ",") + response.Address = vals.Get("myip") + + if sharedSecret != appConfig.SharedSecret { + log.Println(fmt.Sprintf("Invalid shared secret: %s", sharedSecret)) + response.Success = false + response.Message = "Invalid Credentials" + return response + } + + for _, domain := range response.Domains { + if domain == "" { + response.Success = false + response.Message = fmt.Sprintf("Domain not set") + log.Println("Domain not set") + return response + } + } + + // kept in the response for compatibility reasons + response.Domain = strings.Join(response.Domains, ",") + + if ipparser.ValidIP4(response.Address) { + response.AddrType = "A" + } else if ipparser.ValidIP6(response.Address) { + response.AddrType = "AAAA" + } else { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + + if err != nil { + response.Success = false + response.Message = fmt.Sprintf("%q is neither a valid IPv4 nor IPv6 address", r.RemoteAddr) + log.Println(fmt.Sprintf("Invalid address: %q", r.RemoteAddr)) + return response + } + + // @todo refactor this code to remove duplication + if ipparser.ValidIP4(ip) { + response.AddrType = "A" + response.Address = ip + } else if ipparser.ValidIP6(ip) { + response.AddrType = "AAAA" + response.Address = ip + } else { + response.Success = false + response.Message = fmt.Sprintf("%s is neither a valid IPv4 nor IPv6 address", response.Address) + log.Println(fmt.Sprintf("Invalid address: %s", response.Address)) + return response + } + } + + response.Success = true + + return response +} From 4f4edca28c8e2b8c0290ce902123d10669450fe6 Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Mon, 30 Dec 2019 10:19:17 +0000 Subject: [PATCH 2/6] Refactored code to be more concise. Added some tests and updated README.md --- README.md | 47 +++++++ rest-api/ipparser_test.go | 2 +- rest-api/main.go | 204 +++++++++++++++++-------------- rest-api/request_handler.go | 88 ++----------- rest-api/request_handler_test.go | 86 ++++++++++--- 5 files changed, 242 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index d814420..0f07c87 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,45 @@ 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 +## DynDNS compatible API + +This package contains a DynDNS compatible handler for convenience and for use cases +where clients cannot be modified to use the JSON responses and/or URL scheme outlined +above. + +This has been tested with a number of routers. Just point the router to your DDNS domain +for updates. + +The handlers will listen on: +* /nic/update +* /v2/update +* /v3/update + + +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. + +D-Link DIR-842: + +Another router that has been tested is from the D-Link router line where you need to fill the +details in on the Web Interface. The values are self-explanatory. Under the server (once you chosen Manual) +you need to enter you DDNS server's hostname or IP. The protocol used by the router will be the +dyndns2 by default and cannot be changed. + + ### Fields * `secret`: The shared secret set in `envfile` @@ -76,6 +115,14 @@ http://myhost.mydomain.tld:8080/update?secret=changeme&domain=foo&addr=1.2.3.4 redirected to the same domain separated by comma, so "foo,bar" * `addr`: IPv4 or IPv6 address of the name record + +For the DynDNS compatible fields please see Dyn's documentation here: + +``` +https://help.dyn.com/remote-access-api/perform-update/ +``` + + ## Accessing the REST API log Just run diff --git a/rest-api/ipparser_test.go b/rest-api/ipparser_test.go index 7fbfdf1..357d650 100644 --- a/rest-api/ipparser_test.go +++ b/rest-api/ipparser_test.go @@ -2,7 +2,7 @@ package main import ( "testing" - "dyndns/ipparser" + "./ipparser" ) func TestValidIP4ToReturnTrueOnValidAddress(t *testing.T) { diff --git a/rest-api/main.go b/rest-api/main.go index 3a4f6e2..dc0f45a 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -1,119 +1,137 @@ package main import ( - "log" - "fmt" - "net/http" - "io/ioutil" - "os" - "bufio" - "os/exec" - "bytes" - "encoding/json" - - "github.com/gorilla/mux" + "bufio" + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + + "github.com/gorilla/mux" ) var appConfig = &Config{} func main() { - appConfig.LoadConfig("/etc/dyndns.json") + appConfig.LoadConfig("/etc/dyndns.json") - router := mux.NewRouter().StrictSlash(true) - router.HandleFunc("/update", Update).Methods("GET") - router.HandleFunc("/nic/update", DynUpdate).Methods("GET") - router.HandleFunc("/v2/update", DynUpdate).Methods("GET") - router.HandleFunc("/v3/update", DynUpdate).Methods("GET") + router := mux.NewRouter().StrictSlash(true) + router.HandleFunc("/update", Update).Methods("GET") - log.Println(fmt.Sprintf("Serving dyndns REST services on 0.0.0.0:8080...")) - log.Fatal(http.ListenAndServe(":8080", router)) + /* DynDNS compatible handlers. Most routers will invoke /nic/update */ + router.HandleFunc("/nic/update", DynUpdate).Methods("GET") + router.HandleFunc("/v2/update", DynUpdate).Methods("GET") + router.HandleFunc("/v3/update", DynUpdate).Methods("GET") + + log.Println(fmt.Sprintf("Serving dyndns REST services on 0.0.0.0:8080...")) + log.Fatal(http.ListenAndServe(":8080", router)) } func DynUpdate(w http.ResponseWriter, r *http.Request) { - response := BuildDynResponseFromRequest(r, appConfig) - - if response.Success == false { - if response.Message == "Domain not set" { - w.Write([]byte("notfqdn\n")) - } else { - w.Write([]byte("badauth\n")) - } - return + extractor := RequestDataExtractor{ + Address: func(r *http.Request) string { return r.URL.Query().Get("myip") }, + Secret: func(r *http.Request) string { _, sharedSecret, ok := r.BasicAuth() + if !ok || sharedSecret == "" { + sharedSecret = r.URL.Query().Get("password") + } + + return sharedSecret + }, + Domain: func(r *http.Request) string { return r.URL.Query().Get("hostname") }, } + response := BuildWebserviceResponseFromRequest(r, appConfig, extractor) - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) + if response.Success == false { + if response.Message == "Domain not set" { + w.Write([]byte("notfqdn\n")) + } else { + w.Write([]byte("badauth\n")) + } + return + } - if result != "" { - response.Success = false - response.Message = result + for _, domain := range response.Domains { + result := UpdateRecord(domain, response.Address, response.AddrType) - w.Write([]byte("dnserr\n")) - return - } - } + if result != "" { + response.Success = false + response.Message = result - response.Success = true - response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", response.AddrType, response.Domain, response.Address) + w.Write([]byte("dnserr\n")) + return + } + } - w.Write([]byte(fmt.Sprintf("good %s\n", response.Address))) + response.Success = true + response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", response.AddrType, response.Domain, response.Address) + + w.Write([]byte(fmt.Sprintf("good %s\n", response.Address))) } func Update(w http.ResponseWriter, r *http.Request) { - response := BuildWebserviceResponseFromRequest(r, appConfig) - - if response.Success == false { - json.NewEncoder(w).Encode(response) - return - } - - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) - - if result != "" { - response.Success = false - response.Message = result - - json.NewEncoder(w).Encode(response) - return - } - } - - response.Success = true - response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", response.AddrType, response.Domain, response.Address) - - json.NewEncoder(w).Encode(response) + extractor := RequestDataExtractor{ + Address: func(r *http.Request) string { return r.URL.Query().Get("addr") }, + Secret: func(r *http.Request) string { return r.URL.Query().Get("secret") }, + Domain: func(r *http.Request) string { return r.URL.Query().Get("domain") }, + } + response := BuildWebserviceResponseFromRequest(r, appConfig, extractor) + + if response.Success == false { + json.NewEncoder(w).Encode(response) + return + } + + for _, domain := range response.Domains { + result := UpdateRecord(domain, response.Address, response.AddrType) + + if result != "" { + response.Success = false + response.Message = result + + json.NewEncoder(w).Encode(response) + return + } + } + + response.Success = true + response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", response.AddrType, response.Domain, response.Address) + + json.NewEncoder(w).Encode(response) } func UpdateRecord(domain string, ipaddr string, addrType string) string { - log.Println(fmt.Sprintf("%s record update request: %s -> %s", addrType, domain, ipaddr)) - - f, err := ioutil.TempFile(os.TempDir(), "dyndns") - if err != nil { - return err.Error() - } - - 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", domain, appConfig.Domain, addrType)) - w.WriteString(fmt.Sprintf("update add %s.%s %v %s %s\n", domain, appConfig.Domain, appConfig.RecordTTL, addrType, ipaddr)) - 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 - err = cmd.Run() - if err != nil { - return err.Error() + ": " + stderr.String() - } - - return out.String() + log.Println(fmt.Sprintf("%s record update request: %s -> %s", addrType, domain, ipaddr)) + + f, err := ioutil.TempFile(os.TempDir(), "dyndns") + if err != nil { + return err.Error() + } + + 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", domain, appConfig.Domain, addrType)) + w.WriteString(fmt.Sprintf("update add %s.%s %v %s %s\n", domain, appConfig.Domain, appConfig.RecordTTL, addrType, ipaddr)) + 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 + err = cmd.Run() + if err != nil { + return err.Error() + ": " + stderr.String() + } + + return out.String() } diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 6b58c40..6f018d1 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -1,15 +1,21 @@ package main import ( - "log" "fmt" + "log" "net" "net/http" "strings" - "dyndns/ipparser" + "./ipparser" ) +type RequestDataExtractor struct { + Address func (request *http.Request) string + Secret func (request *http.Request) string + Domain func (request *http.Request) string +} + type WebserviceResponse struct { Success bool Message string @@ -19,15 +25,12 @@ type WebserviceResponse struct { AddrType string } -func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config) WebserviceResponse { +func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors RequestDataExtractor) WebserviceResponse { response := WebserviceResponse{} - var sharedSecret string - - vals := r.URL.Query() - sharedSecret = vals.Get("secret") - response.Domains = strings.Split(vals.Get("domain"), ",") - response.Address = vals.Get("addr") + sharedSecret := extractors.Secret(r) + response.Domains = strings.Split(extractors.Domain(r), ",") + response.Address = extractors.Address(r) if sharedSecret != appConfig.SharedSecret { log.Println(fmt.Sprintf("Invalid shared secret: %s", sharedSecret)) @@ -81,70 +84,3 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config) Webs return response } - -func BuildDynResponseFromRequest(r *http.Request, appConfig *Config) WebserviceResponse { - response := WebserviceResponse{} - - var sharedSecret string - - vals := r.URL.Query() - _, sharedSecret, ok := r.BasicAuth() - if !ok || sharedSecret == "" { - sharedSecret = vals.Get("password") - } - - response.Domains = strings.Split(vals.Get("hostname"), ",") - response.Address = vals.Get("myip") - - if sharedSecret != appConfig.SharedSecret { - log.Println(fmt.Sprintf("Invalid shared secret: %s", sharedSecret)) - response.Success = false - response.Message = "Invalid Credentials" - return response - } - - for _, domain := range response.Domains { - if domain == "" { - response.Success = false - response.Message = fmt.Sprintf("Domain not set") - log.Println("Domain not set") - return response - } - } - - // kept in the response for compatibility reasons - response.Domain = strings.Join(response.Domains, ",") - - if ipparser.ValidIP4(response.Address) { - response.AddrType = "A" - } else if ipparser.ValidIP6(response.Address) { - response.AddrType = "AAAA" - } else { - ip, _, err := net.SplitHostPort(r.RemoteAddr) - - if err != nil { - response.Success = false - response.Message = fmt.Sprintf("%q is neither a valid IPv4 nor IPv6 address", r.RemoteAddr) - log.Println(fmt.Sprintf("Invalid address: %q", r.RemoteAddr)) - return response - } - - // @todo refactor this code to remove duplication - if ipparser.ValidIP4(ip) { - response.AddrType = "A" - response.Address = ip - } else if ipparser.ValidIP6(ip) { - response.AddrType = "AAAA" - response.Address = ip - } else { - response.Success = false - response.Message = fmt.Sprintf("%s is neither a valid IPv4 nor IPv6 address", response.Address) - log.Println(fmt.Sprintf("Invalid address: %s", response.Address)) - return response - } - } - - response.Success = true - - return response -} diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index 01f2002..c070dfe 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -5,12 +5,30 @@ import ( "net/http" ) +var defaultExtractor = RequestDataExtractor{ + Address: func(r *http.Request) string { return r.URL.Query().Get("addr") }, + Secret: func(r *http.Request) string { return r.URL.Query().Get("secret") }, + Domain: func(r *http.Request) string { return r.URL.Query().Get("domain") }, +} + +var dynExtractor = RequestDataExtractor{ + Address: func(r *http.Request) string { return r.URL.Query().Get("myip") }, + Secret: func(r *http.Request) string { _, sharedSecret, ok := r.BasicAuth() + if !ok || sharedSecret == "" { + sharedSecret = r.URL.Query().Get("password") + } + + return sharedSecret + }, + Domain: func(r *http.Request) string { return r.URL.Query().Get("hostname") }, +} + func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo&addr=1.2.3.4", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo&addr=1.2.3.4", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != true { t.Fatalf("Expected WebserviceResponse.Success to be true") @@ -33,8 +51,8 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGi var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") @@ -45,8 +63,8 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidSecre var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=foo", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=foo", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") @@ -57,8 +75,8 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoDomainIsGi var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=changeme", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=changeme", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") @@ -69,8 +87,8 @@ func TestBuildWebserviceResponseFromRequestWithMultipleDomains(t *testing.T) { var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo,bar&addr=1.2.3.4", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo,bar&addr=1.2.3.4", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != true { t.Fatalf("Expected WebserviceResponse.Success to be true") @@ -93,8 +111,8 @@ func TestBuildWebserviceResponseFromRequestWithMalformedMultipleDomains(t *testi var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo,&addr=1.2.3.4", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo,&addr=1.2.3.4", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") @@ -106,7 +124,7 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoAddressIsG appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") @@ -117,8 +135,46 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidAddre var appConfig = &Config{} appConfig.SharedSecret = "changeme" - req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo&addr=1.41:2", nil) - result := BuildWebserviceResponseFromRequest(req, appConfig) + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo&addr=1.41:2", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) + + if result.Success != false { + t.Fatalf("Expected WebserviceResponse.Success to be false") + } +} + +func TestBuildWebserviceResponseFromRequestToReturnValidObjectWithDynExtractor(t *testing.T) { + var appConfig = &Config{} + appConfig.SharedSecret = "changeme" + + req, _ := http.NewRequest("GET", "/nic/update?hostname=foo&myip=1.2.3.4", nil) + req.Header.Add("Authorization", "Basic dXNlcm5hbWU6Y2hhbmdlbWU=") // This is the base-64 encoded value of "username:changeme" + + result := BuildWebserviceResponseFromRequest(req, appConfig, dynExtractor) + + if result.Success != true { + t.Fatalf("Expected WebserviceResponse.Success to be true") + } + + if result.Domain != "foo" { + t.Fatalf("Expected WebserviceResponse.Domain to be foo") + } + + if result.Address != "1.2.3.4" { + t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") + } + + if result.AddrType != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A") + } +} + +func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGivenWithDynExtractor(t *testing.T) { + var appConfig = &Config{} + appConfig.SharedSecret = "changeme" + + req, _ := http.NewRequest("GET", "/nic/update", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, dynExtractor) if result.Success != false { t.Fatalf("Expected WebserviceResponse.Success to be false") From 6147be9dd57ac659f26eabc62e2edf7f05c2d00f Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Mon, 30 Dec 2019 10:22:10 +0000 Subject: [PATCH 3/6] Fixed the package name which was causing Travis to fail the build. --- rest-api/ipparser_test.go | 2 +- rest-api/request_handler.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rest-api/ipparser_test.go b/rest-api/ipparser_test.go index 357d650..7fbfdf1 100644 --- a/rest-api/ipparser_test.go +++ b/rest-api/ipparser_test.go @@ -2,7 +2,7 @@ package main import ( "testing" - "./ipparser" + "dyndns/ipparser" ) func TestValidIP4ToReturnTrueOnValidAddress(t *testing.T) { diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 6f018d1..a1b90b2 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -7,7 +7,7 @@ import ( "net/http" "strings" - "./ipparser" + "dyndns/ipparser" ) type RequestDataExtractor struct { From 6127c19b8df83ccd4c1309f48163fb6c6a7b276d Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Mon, 30 Dec 2019 19:32:21 +0000 Subject: [PATCH 4/6] Implemented resource record expiry. --- envfile | 3 +- rest-api/config.go | 1 + rest-api/{ipparser => }/ipparser.go | 2 +- rest-api/ipparser_test.go | 7 +-- rest-api/main.go | 95 +++++++++++++++++++++++++++++ rest-api/request_handler.go | 10 ++- setup.sh | 4 +- 7 files changed, 109 insertions(+), 13 deletions(-) rename rest-api/{ipparser => }/ipparser.go (95%) 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 From 764d52cf56faa2e9ba41def4b902bd624632b7ed Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Tue, 31 Dec 2019 17:22:39 +0000 Subject: [PATCH 5/6] README: updated style around example commands --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b471486..dcd93e0 100644 --- a/README.md +++ b/README.md @@ -85,15 +85,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 +``` From da75f8f07a187b016d24fd1fcbee1e739cf0957b Mon Sep 17 00:00:00 2001 From: Szabolcs Gyurko Date: Thu, 2 Jan 2020 15:14:12 +0000 Subject: [PATCH 6/6] Fixed README merge error --- README.md | 50 +++++--------------------------------------------- 1 file changed, 5 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index dcd93e0..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,48 +69,6 @@ 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 -## DynDNS compatible API - -This package contains a DynDNS compatible handler for convenience and for use cases -where clients cannot be modified to use the JSON responses and/or URL scheme outlined -above. - -This has been tested with a number of routers. Just point the router to your DDNS domain -for updates. - -The handlers will listen on: -* /nic/update -* /v2/update -* /v3/update - - -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. - -D-Link DIR-842: - -Another router that has been tested is from the D-Link router line where you need to fill the -details in on the Web Interface. The values are self-explanatory. Under the server (once you chosen Manual) -you need to enter you DDNS server's hostname or IP. The protocol used by the router will be the -dyndns2 by default and cannot be changed. - ### Fields @@ -127,8 +86,6 @@ https://help.dyn.com/remote-access-api/perform-update/ ``` -<<<<<<< HEAD -======= ### DynDNS compatible API This package contains a DynDNS compatible handler for convenience and for use cases @@ -151,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. @@ -171,7 +132,6 @@ you need to enter you DDNS server's hostname or IP. The protocol used by the rou dyndns2 by default and cannot be changed. ->>>>>>> 9a5f524b6d026696c8aa6b5fa491104279e08c9f ## Accessing the REST API log Just run