From 60ba35e4af2aadf117b96828fa5d124f3fa6a5bb Mon Sep 17 00:00:00 2001 From: Alexander Kune Date: Wed, 22 Apr 2020 22:45:40 +0200 Subject: [PATCH 01/15] feat: allow updating ip4 and ip6 addresses in one request --- rest-api/main.go | 49 ++++++++++-------- rest-api/request_handler.go | 85 ++++++++++++++++++-------------- rest-api/request_handler_test.go | 28 +++++------ 3 files changed, 91 insertions(+), 71 deletions(-) diff --git a/rest-api/main.go b/rest-api/main.go index d556ad4..8cf217a 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -55,22 +55,24 @@ func DynUpdate(w http.ResponseWriter, r *http.Request) { return } - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) + for _, address := range response.Addresses { + for _, domain := range response.Domains { + result := UpdateRecord(domain, address.Address, address.AddrType) - if result != "" { - response.Success = false - response.Message = result + if result != "" { + response.Success = false + response.Message = result - w.Write([]byte("dnserr\n")) - return + 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) + response.Success = true + response.Message = fmt.Sprintf("Updated %s record for %s to IP address %s", address.AddrType, response.Domain, address.Address) - w.Write([]byte(fmt.Sprintf("good %s\n", response.Address))) + w.Write([]byte(fmt.Sprintf("good %s\n", address.Address))) + } } func Update(w http.ResponseWriter, r *http.Request) { @@ -86,20 +88,25 @@ func Update(w http.ResponseWriter, r *http.Request) { return } - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) + for _, address := range response.Addresses { + for _, domain := range response.Domains { + result := UpdateRecord(domain, address.Address, address.AddrType) - if result != "" { - response.Success = false - response.Message = result + if result != "" { + response.Success = false + response.Message = result - json.NewEncoder(w).Encode(response) - return + 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) + response.Success = true + if (len(response.Message) == 0) { + response.Message += "; "; + } + response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", address.AddrType, response.Domain, address.Address) + } json.NewEncoder(w).Encode(response) } diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 2199454..5928aff 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -18,21 +18,41 @@ type RequestDataExtractor struct { Domain func(request *http.Request) string } -type WebserviceResponse struct { - Success bool - Message string - Domain string - Domains []string +type Address struct { Address string AddrType string } +type WebserviceResponse struct { + Success bool + Message string + Domain string + Domains []string + Addresses []Address + //Address string + //AddrType string +} + +func ParseAddress(address string) (Address, error) { + if ipparser.ValidIP4(address) { + return Address{Address: address, AddrType: "A"}, nil + } else if ipparser.ValidIP6(address) { + return Address{Address: address, AddrType: "AAAA"}, nil + } + return Address{}, errors.New(fmt.Sprintf("Invalid ip address: %s", address)) +} + func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors RequestDataExtractor) WebserviceResponse { response := WebserviceResponse{} sharedSecret := extractors.Secret(r) response.Domains = strings.Split(extractors.Domain(r), ",") - response.Address = extractors.Address(r) + for _, address := range strings.Split(extractors.Address(r), ",") { + var parsedAddress, error = ParseAddress(address); + if error == nil { + response.Addresses = append(response.Addresses, parsedAddress) + } + } if sharedSecret != appConfig.SharedSecret { log.Println(fmt.Sprintf("Invalid shared secret: %s", sharedSecret)) @@ -53,39 +73,32 @@ 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) { - response.AddrType = "A" - } else if ipparser.ValidIP6(response.Address) { - response.AddrType = "AAAA" - } else { - var ip string - var err error - - ip, err = getUserIP(r) - if ip == "" { - ip, _, err = net.SplitHostPort(r.RemoteAddr) - } + if (len(response.Addresses) == 0) { + var ip string + var err error - 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 - } + ip, err = getUserIP(r) + if ip == "" { + ip, _, err = net.SplitHostPort(r.RemoteAddr) + } - // @todo refactor this code to remove duplication - if ipparser.ValidIP4(ip) { - response.AddrType = "A" - } else if ipparser.ValidIP6(ip) { - response.AddrType = "AAAA" - } 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 - } + 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 + } - response.Address = ip + var address Address + address, err = ParseAddress(ip) + if err == nil { + response.Addresses = append(response.Addresses, address); + } else { + response.Success = false + response.Message = fmt.Sprintf("%s is neither a valid IPv4 nor IPv6 address", address) + log.Println(fmt.Sprintf(err.Error())) + return response + } } response.Success = true diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index ba6681a..a7b3744 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -39,11 +39,11 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.Address != "1.2.3.4" { + if result.Addresses[0].Address != "1.2.3.4" { t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") } - if result.AddrType != "A" { + if result.Addresses[0].AddrType != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } @@ -64,11 +64,11 @@ func TestBuildWebserviceResponseFromRequestWithXRealIPHeaderToReturnValidObject( t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.Address != "1.2.3.4" { + if result.Addresses[0].Address != "1.2.3.4" { t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") } - if result.AddrType != "A" { + if result.Addresses[0].AddrType != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } @@ -86,15 +86,15 @@ func TestBuildWebserviceResponseFromRequestWithXForwardedForHeaderToReturnValidO } if result.Domain != "foo" { - t.Fatalf("Expected WebserviceResponse.Domain to be foo") + t.Fatalf("Expected WebserviceResponse.Domain to be foo but was %s", result.Domain) } - if result.Address != "1.2.3.4" { - t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") + if result.Addresses[0].Address != "1.2.3.4" { + t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Addresses[0].Address) } - if result.AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A") + if result.Addresses[0].AddrType != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Addresses[0].AddrType) } } @@ -208,15 +208,15 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObjectWithDynExtractor(t } if result.Domain != "foo" { - t.Fatalf("Expected WebserviceResponse.Domain to be foo") + t.Fatalf("Expected WebserviceResponse.Domain to be foo but was %s", result.Domain) } - if result.Address != "1.2.3.4" { - t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") + if result.Addresses[0].Address != "1.2.3.4" { + t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Addresses[0].Address) } - if result.AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A") + if result.Addresses[0].AddrType != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Addresses[0].AddrType) } } From f8358d5a4904d566ddd358268d9c055eb68c9be6 Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 16:42:59 +0200 Subject: [PATCH 02/15] Add go module files * This enables the program to be compiled outside the go source directory and makes the project easily usable independently --- rest-api/go.mod | 5 +++++ rest-api/go.sum | 2 ++ rest-api/ipparser_test.go | 2 +- rest-api/request_handler.go | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 rest-api/go.mod create mode 100644 rest-api/go.sum diff --git a/rest-api/go.mod b/rest-api/go.mod new file mode 100644 index 0000000..f381715 --- /dev/null +++ b/rest-api/go.mod @@ -0,0 +1,5 @@ +module github.com/dprandzioch/docker-ddns/rest-api + +go 1.14 + +require github.com/gorilla/mux v1.8.0 diff --git a/rest-api/go.sum b/rest-api/go.sum new file mode 100644 index 0000000..5350288 --- /dev/null +++ b/rest-api/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= diff --git a/rest-api/ipparser_test.go b/rest-api/ipparser_test.go index 79f13b6..01d54c0 100644 --- a/rest-api/ipparser_test.go +++ b/rest-api/ipparser_test.go @@ -1,7 +1,7 @@ package main import ( - "dyndns/ipparser" + "github.com/dprandzioch/docker-ddns/rest-api/ipparser" "testing" ) diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 2199454..a65cfc1 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - "dyndns/ipparser" + "github.com/dprandzioch/docker-ddns/rest-api/ipparser" ) type RequestDataExtractor struct { From 4141c3d90e0de67a62564fd2dee05daa42659abc Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 16:54:06 +0200 Subject: [PATCH 03/15] Replace encoded base64 value to clear text --- rest-api/request_handler_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index ba6681a..a39b492 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "net/http" "testing" ) @@ -199,7 +200,7 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObjectWithDynExtractor(t 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" + req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("username:changeme"))) result := BuildWebserviceResponseFromRequest(req, appConfig, dynExtractor) From 570cc36f61c3a5d79e9f49210ace86637ec4849c Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 17:15:31 +0200 Subject: [PATCH 04/15] Add support for command line arguments * Add port configuration value --- rest-api/config.go | 41 ++++++++++++++++++++++++++++++++++++++++- rest-api/main.go | 14 ++++++++------ rest-api/utils.go | 17 +++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 rest-api/utils.go diff --git a/rest-api/config.go b/rest-api/config.go index 4a591b4..e7d7bf4 100644 --- a/rest-api/config.go +++ b/rest-api/config.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "os" ) @@ -12,9 +13,17 @@ type Config struct { Domain string NsupdateBinary string RecordTTL int + Port int } -func (conf *Config) LoadConfig(path string) { +type ConfigFlags struct { + Config + ConfigFile string + DoNotLoadConfig bool + LogLevel int +} + +func (conf *Config) loadConfigFromFile(path string) { file, err := os.Open(path) if err != nil { panic(err) @@ -25,3 +34,33 @@ func (conf *Config) LoadConfig(path string) { panic(err) } } + +func (flagsConf *ConfigFlags) setupFlags() { + flag.BoolVar(&flagsConf.DoNotLoadConfig, "noConfig", false, "Do not load the config file") + flag.StringVar(&flagsConf.ConfigFile, "c", "/etc/dyndns.json", "The configuration file") + flag.StringVar(&flagsConf.SharedSecret, "sharedSecret", "", "The shared secret (default a generated random string)") + flag.StringVar(&flagsConf.Server, "server", "localhost", "The address of the bind server") + flag.StringVar(&flagsConf.Zone, "zone", "localhost", "Zone") + flag.StringVar(&flagsConf.Domain, "domain", "localhost", "Domain") + flag.StringVar(&flagsConf.NsupdateBinary, "nsupdateBinary", "nsupdate", "Path to nsupdate program") + flag.IntVar(&flagsConf.RecordTTL, "recordTTL", 300, "RecordTTL") + flag.IntVar(&flagsConf.Port, "p", 8080, "Port") + flag.IntVar(&flagsConf.LogLevel, "log", 0, "Set the log level") +} + +// LoadConfig loads config values from the config file and from the passed arguments. +// Gives command line arguments precedence. +func (flagsConf *ConfigFlags) LoadConfig() { + flagsConf.setupFlags() + flag.Parse() + + if !flagsConf.DoNotLoadConfig { + flagsConf.loadConfigFromFile(flagsConf.ConfigFile) + flag.Parse() // Parse a second time to overwrite settings from the loaded file + } + + // Fix unsafe config values + if flagsConf.SharedSecret == "" { + flagsConf.SharedSecret = randomString() + } +} diff --git a/rest-api/main.go b/rest-api/main.go index d556ad4..ebc9b7d 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -14,10 +14,10 @@ import ( "github.com/gorilla/mux" ) -var appConfig = &Config{} +var appConfig = &ConfigFlags{} func main() { - appConfig.LoadConfig("/etc/dyndns.json") + appConfig.LoadConfig() router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/update", Update).Methods("GET") @@ -27,8 +27,10 @@ func main() { 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)) + listenTo := fmt.Sprintf("%s:%d", "", appConfig.Port) + + log.Println(fmt.Sprintf("Serving dyndns REST services on " + listenTo + "...")) + log.Fatal(http.ListenAndServe(listenTo, router)) } func DynUpdate(w http.ResponseWriter, r *http.Request) { @@ -44,7 +46,7 @@ func DynUpdate(w http.ResponseWriter, r *http.Request) { }, Domain: func(r *http.Request) string { return r.URL.Query().Get("hostname") }, } - response := BuildWebserviceResponseFromRequest(r, appConfig, extractor) + response := BuildWebserviceResponseFromRequest(r, &appConfig.Config, extractor) if response.Success == false { if response.Message == "Domain not set" { @@ -79,7 +81,7 @@ func Update(w http.ResponseWriter, r *http.Request) { 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) + response := BuildWebserviceResponseFromRequest(r, &appConfig.Config, extractor) if response.Success == false { json.NewEncoder(w).Encode(response) diff --git a/rest-api/utils.go b/rest-api/utils.go new file mode 100644 index 0000000..62bd2df --- /dev/null +++ b/rest-api/utils.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "crypto/rand" + "encoding/base64" +) + +func randomString() string { + random := make([]byte, 32) + _, err := rand.Read(random) + if err != nil { + fmt.Println("error:", err) + return "8Passw0RT!" + } + return base64.StdEncoding.EncodeToString(random) +} From 63660f50dd6aae1505005cbf9ba8a2c3ecee32db Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 18:38:41 +0200 Subject: [PATCH 05/15] Refactor nsupdate function into a new file * Update nsupdate code to use pipe instead of creating a new file * Add escaping for user input variables * Add interface for DNS update client implementations --- rest-api/main.go | 57 +++++++++------------ rest-api/nsupdate.go | 119 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 rest-api/nsupdate.go diff --git a/rest-api/main.go b/rest-api/main.go index ebc9b7d..76c8302 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -1,15 +1,10 @@ package main import ( - "bufio" - "bytes" "encoding/json" "fmt" - "io/ioutil" "log" "net/http" - "os" - "os/exec" "github.com/gorilla/mux" ) @@ -58,7 +53,13 @@ func DynUpdate(w http.ResponseWriter, r *http.Request) { } for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) + recordUpdate := RecordUpdateRequest{ + domain: domain, + ipaddr: response.Address, + addrType: response.AddrType, + ddnskey: "", + } + result := recordUpdate.updateRecord() if result != "" { response.Success = false @@ -89,7 +90,13 @@ func Update(w http.ResponseWriter, r *http.Request) { } for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) + recordUpdate := RecordUpdateRequest{ + domain: domain, + ipaddr: response.Address, + addrType: response.AddrType, + ddnskey: "", + } + result := recordUpdate.updateRecord() if result != "" { response.Success = false @@ -106,35 +113,17 @@ func Update(w http.ResponseWriter, r *http.Request) { 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)) +func (r RecordUpdateRequest) updateRecord() string { + var nsupdate NSUpdateInterface = NewNSUpdate() + nsupdate.UpdateRecord(r) + result := nsupdate.Close() - f, err := ioutil.TempFile(os.TempDir(), "dyndns") - if err != nil { - return err.Error() + status := "succeeded" + if result != "" { + status = "failed, error: " + result } - 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() - } + log.Println(fmt.Sprintf("%s record update request: %s -> %s %s", r.addrType, r.domain, r.ipaddr, status)) - return out.String() + return result } diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go new file mode 100644 index 0000000..c4b46c3 --- /dev/null +++ b/rest-api/nsupdate.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log" + "os/exec" + "strings" + "unicode" +) + +// NSUpdateInterface is the interface to a client which can update a DNS record +type NSUpdateInterface interface { + UpdateRecord(r RecordUpdateRequest) + Close() string +} + +// RecordUpdateRequest data representing a update request +type RecordUpdateRequest struct { + domain string + ipaddr string + addrType string + ddnskey string +} + +// NSUpdate holds resources need for an open nsupdate program +type NSUpdate struct { + cmd *exec.Cmd + w *bufio.Writer + stdinPipe io.WriteCloser + out bytes.Buffer + stderr bytes.Buffer +} + +// NewNSUpdate starts the nsupdate program +func NewNSUpdate() *NSUpdate { + var err error + + var nsupdate = &NSUpdate{} + nsupdate.cmd = exec.Command(appConfig.NsupdateBinary) + + nsupdate.stdinPipe, err = nsupdate.cmd.StdinPipe() + if err != nil { + log.Println(err.Error() + ": " + nsupdate.stderr.String()) + return nil + } + + nsupdate.cmd.Stdout = &nsupdate.out + nsupdate.cmd.Stderr = &nsupdate.stderr + err = nsupdate.cmd.Start() + if err != nil { + log.Println(err.Error() + ": " + nsupdate.stderr.String()) + return nil + } + nsupdate.w = bufio.NewWriter(nsupdate.stdinPipe) + + return nsupdate +} + +func (nsupdate *NSUpdate) write(format string, a ...interface{}) { + command := fmt.Sprintf(format, a...) + if appConfig.LogLevel >= 1 { + logCommand := strings.Replace(command, "\n", "\\n", -1) // ReplaceAll + log.Println("nsupdate: " + logCommand) + } + nsupdate.w.WriteString(command) +} + +// Close sends the quit command and waits for the response which is then returned. +func (nsupdate *NSUpdate) Close() string { + var err error + + nsupdate.write("quit\n") + nsupdate.w.Flush() + nsupdate.stdinPipe.Close() + + err = nsupdate.cmd.Wait() + if err != nil { + return err.Error() + ": " + nsupdate.stderr.String() + } + + return nsupdate.out.String() +} + +func isRune(r rune, allow string) bool { + for _, c := range allow { + if r == c { + return true + } + } + return false +} + +func escape(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) && !isRune(r, ".+-_/=") + }) +} + +// UpdateRecord sends the record update request to the nsupdate program +func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { + fqdn := escape(r.domain) + if appConfig.Zone != "" { + fqdn = escape(r.domain) + "." + appConfig.Zone + } + + if r.ddnskey != "" { + fqdnN := strings.TrimLeft(fqdn, ".") + nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", fqdnN, escape(r.ddnskey)) + } + + nsupdate.write("server %s\n", appConfig.Server) + nsupdate.write("zone %s\n", appConfig.Zone) + nsupdate.write("update delete %s %s\n", fqdn, r.addrType) + nsupdate.write("update add %s %v %s %s\n", fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipaddr)) + nsupdate.write("send\n") +} From 96ef7aa8d32d2a6454d9d1091a04a8336cf151d5 Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 19:21:51 +0200 Subject: [PATCH 06/15] Change builder to golang:1.14 --- Dockerfile | 7 ++----- Makefile | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index dea5495..0244847 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,4 @@ -FROM debian:buster as builder -RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ - apt-get install -q -y golang git-core && \ - apt-get clean +FROM golang:1.14 as builder ENV GOPATH=/root/go RUN mkdir -p /root/go/src @@ -19,7 +16,7 @@ RUN chmod 770 /var/cache/bind COPY setup.sh /root/setup.sh RUN chmod +x /root/setup.sh COPY named.conf.options /etc/bind/named.conf.options -COPY --from=builder /root/go/bin/dyndns /root/dyndns +COPY --from=builder /root/go/bin/rest-api /root/dyndns EXPOSE 53 8080 CMD ["sh", "-c", "/root/setup.sh ; service bind9 start ; /root/dyndns"] diff --git a/Makefile b/Makefile index 80b5a67..4bdf49f 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,13 @@ console: docker run -it -p 8080:8080 -p 53:53 -p 53:53/udp --rm davd/docker-ddns:latest bash devconsole: - docker run -it --rm -v ${PWD}/rest-api:/usr/src/app -w /usr/src/app golang:1.8.5 bash + docker run -it --rm -v ${PWD}/rest-api:/usr/src/app -w /usr/src/app golang:1.14 bash server_test: docker run -it -p 8080:8080 -p 53:53 -p 53:53/udp --env-file envfile --rm davd/docker-ddns:latest unit_tests: - docker run -it --rm -v ${PWD}/rest-api:/go/src/dyndns -w /go/src/dyndns golang:1.8.5 /bin/bash -c "go get && go test -v" + docker run -it --rm -v ${PWD}/rest-api:/go/src/dyndns -w /go/src/dyndns golang:1.14 /bin/bash -c "go get && go test -v" api_test: curl "http://localhost:8080/update?secret=changeme&domain=foo&addr=1.2.3.4" From 8b1f28592270f0880a4801a188c5ba3beb6ed518 Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 21:49:11 +0200 Subject: [PATCH 07/15] Refactor requestDataExtractor and add ddnskey --- rest-api/main.go | 88 ++++++++++++++---------------- rest-api/request_data_extractor.go | 54 ++++++++++++++++++ rest-api/request_handler.go | 9 +-- rest-api/request_handler_test.go | 20 +------ 4 files changed, 98 insertions(+), 73 deletions(-) create mode 100644 rest-api/request_data_extractor.go diff --git a/rest-api/main.go b/rest-api/main.go index 76c8302..8c574e6 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -9,18 +9,28 @@ import ( "github.com/gorilla/mux" ) +type key int + +const ( + responseKey key = iota + extractorKey key = iota +) + var appConfig = &ConfigFlags{} func main() { + defaultExtractor := defaultRequestDataExtractor{} + dynExtractor := dynRequestDataExtractor{} + appConfig.LoadConfig() router := mux.NewRouter().StrictSlash(true) - router.HandleFunc("/update", Update).Methods("GET") + router.Handle("/update", requestRequestDataMiddleware(http.HandlerFunc(update), defaultExtractor)).Methods("GET") /* 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") + router.Handle("/nic/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") + router.Handle("/v2/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") + router.Handle("/v3/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") listenTo := fmt.Sprintf("%s:%d", "", appConfig.Port) @@ -28,20 +38,8 @@ func main() { log.Fatal(http.ListenAndServe(listenTo, router)) } -func DynUpdate(w http.ResponseWriter, r *http.Request) { - 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.Config, extractor) +func dynUpdate(w http.ResponseWriter, r *http.Request) { + response := r.Context().Value(responseKey).(WebserviceResponse) if response.Success == false { if response.Message == "Domain not set" { @@ -52,49 +50,45 @@ func DynUpdate(w http.ResponseWriter, r *http.Request) { return } - for _, domain := range response.Domains { - recordUpdate := RecordUpdateRequest{ - domain: domain, - ipaddr: response.Address, - addrType: response.AddrType, - ddnskey: "", - } - result := recordUpdate.updateRecord() - - if result != "" { - response.Success = false - response.Message = result + success := updateDomains(r, &response, func() { + w.Write([]byte("dnserr\n")) + }) - w.Write([]byte("dnserr\n")) - return - } + if !success { + 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) { - 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.Config, extractor) +func update(w http.ResponseWriter, r *http.Request) { + response := r.Context().Value(responseKey).(WebserviceResponse) if response.Success == false { json.NewEncoder(w).Encode(response) return } + success := updateDomains(r, &response, func() { + json.NewEncoder(w).Encode(response) + }) + + if !success { + return + } + + json.NewEncoder(w).Encode(response) +} + +func updateDomains(r *http.Request, response *WebserviceResponse, onError func()) bool { + extractor := r.Context().Value(extractorKey).(requestDataExtractor) + for _, domain := range response.Domains { recordUpdate := RecordUpdateRequest{ domain: domain, ipaddr: response.Address, addrType: response.AddrType, - ddnskey: "", + ddnskey: extractor.DdnsKey(r), } result := recordUpdate.updateRecord() @@ -102,15 +96,15 @@ func Update(w http.ResponseWriter, r *http.Request) { response.Success = false response.Message = result - json.NewEncoder(w).Encode(response) - return + onError() + return false } } 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) + return true } func (r RecordUpdateRequest) updateRecord() string { diff --git a/rest-api/request_data_extractor.go b/rest-api/request_data_extractor.go new file mode 100644 index 0000000..c743976 --- /dev/null +++ b/rest-api/request_data_extractor.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "net/http" +) + +type requestDataExtractor interface { + Address(r *http.Request) string + Secret(r *http.Request) string + Domain(r *http.Request) string + DdnsKey(r *http.Request) string +} + +type defaultRequestDataExtractor struct{} + +func (e defaultRequestDataExtractor) Address(r *http.Request) string { + return r.URL.Query().Get("addr") +} +func (e defaultRequestDataExtractor) Secret(r *http.Request) string { + return r.URL.Query().Get("secret") +} +func (e defaultRequestDataExtractor) Domain(r *http.Request) string { + return r.URL.Query().Get("domain") +} +func (e defaultRequestDataExtractor) DdnsKey(r *http.Request) string { + return r.URL.Query().Get("ddnskey") +} + +type dynRequestDataExtractor struct{ defaultRequestDataExtractor } + +func (e dynRequestDataExtractor) Secret(r *http.Request) string { + _, sharedSecret, ok := r.BasicAuth() + if !ok || sharedSecret == "" { + sharedSecret = r.URL.Query().Get("password") + } + + return sharedSecret +} +func (e dynRequestDataExtractor) Address(r *http.Request) string { + return r.URL.Query().Get("myip") +} +func (e dynRequestDataExtractor) Domain(r *http.Request) string { + return r.URL.Query().Get("hostname") +} + +func requestRequestDataMiddleware(next http.Handler, extractors requestDataExtractor) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), extractorKey, extractors) + response := BuildWebserviceResponseFromRequest(r, &appConfig.Config, extractors) + ctx = context.WithValue(ctx, responseKey, response) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index a65cfc1..b3d2ae1 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -12,12 +12,6 @@ import ( "github.com/dprandzioch/docker-ddns/rest-api/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 @@ -27,7 +21,7 @@ type WebserviceResponse struct { AddrType string } -func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors RequestDataExtractor) WebserviceResponse { +func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors requestDataExtractor) WebserviceResponse { response := WebserviceResponse{} sharedSecret := extractors.Secret(r) @@ -168,4 +162,3 @@ func isPrivateSubnet(ipAddress net.IP) bool { } return false } - diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index a39b492..2dcb1f5 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -6,24 +6,8 @@ import ( "testing" ) -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") }, -} +var defaultExtractor = defaultRequestDataExtractor{} +var dynExtractor = dynRequestDataExtractor{} func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { var appConfig = &Config{} From 208612b9133841fbab82036a6a5ba3e74ac03a35 Mon Sep 17 00:00:00 2001 From: Golit Date: Fri, 28 Aug 2020 22:15:43 +0200 Subject: [PATCH 08/15] Remove dublicate code --- rest-api/request_handler.go | 59 +++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index b3d2ae1..35013cc 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -21,6 +21,15 @@ type WebserviceResponse struct { AddrType string } +func addrType(address string) string { + if ipparser.ValidIP4(address) { + return "A" + } else if ipparser.ValidIP6(address) { + return "AAAA" + } + return "" +} + func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors requestDataExtractor) WebserviceResponse { response := WebserviceResponse{} @@ -47,39 +56,25 @@ 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) { - response.AddrType = "A" - } else if ipparser.ValidIP6(response.Address) { - response.AddrType = "AAAA" - } else { - var ip string - var err error - - ip, err = getUserIP(r) + response.AddrType = addrType(response.Address) + if response.AddrType == "" { // address type unknown. Fall back to get address by request + ip, err := getUserIP(r) if ip == "" { 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 + ip = "" // will fail later } - - // @todo refactor this code to remove duplication - if ipparser.ValidIP4(ip) { - response.AddrType = "A" - } else if ipparser.ValidIP6(ip) { - response.AddrType = "AAAA" - } 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.Address = ip + response.AddrType = addrType(response.Address) + } + + if response.AddrType == "" { + 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 @@ -122,27 +117,27 @@ func inRange(r ipRange, ipAddress net.IP) bool { } var privateRanges = []ipRange{ - ipRange{ + { start: net.ParseIP("10.0.0.0"), end: net.ParseIP("10.255.255.255"), }, - ipRange{ + { start: net.ParseIP("100.64.0.0"), end: net.ParseIP("100.127.255.255"), }, - ipRange{ + { start: net.ParseIP("172.16.0.0"), end: net.ParseIP("172.31.255.255"), }, - ipRange{ + { start: net.ParseIP("192.0.0.0"), end: net.ParseIP("192.0.0.255"), }, - ipRange{ + { start: net.ParseIP("192.168.0.0"), end: net.ParseIP("192.168.255.255"), }, - ipRange{ + { start: net.ParseIP("198.18.0.0"), end: net.ParseIP("198.19.255.255"), }, From 9996546b507c415bf0f5b66f890f4cc1f20bc9cf Mon Sep 17 00:00:00 2001 From: Golit Date: Sat, 29 Aug 2020 17:36:30 +0200 Subject: [PATCH 09/15] Fix json ouput message formating --- rest-api/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest-api/main.go b/rest-api/main.go index e338016..f0a7126 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -102,10 +102,10 @@ func updateDomains(r *http.Request, response *WebserviceResponse, onError func() } response.Success = true - if len(response.Message) == 0 { + if len(response.Message) != 0 { response.Message += "; " } - response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", address.AddrType, response.Domain, address.Address) + response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", address.AddrType, domain, address.Address) } } From ee77e976814c92ffa1854dea8889e171b8919596 Mon Sep 17 00:00:00 2001 From: Golit Date: Sat, 29 Aug 2020 17:37:42 +0200 Subject: [PATCH 10/15] Fix nsupdate call if zone is empty --- rest-api/nsupdate.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go index c4b46c3..fd4169c 100644 --- a/rest-api/nsupdate.go +++ b/rest-api/nsupdate.go @@ -112,7 +112,9 @@ func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { } nsupdate.write("server %s\n", appConfig.Server) - nsupdate.write("zone %s\n", appConfig.Zone) + if appConfig.Zone != "" { + nsupdate.write("zone %s\n", appConfig.Zone) + } nsupdate.write("update delete %s %s\n", fqdn, r.addrType) nsupdate.write("update add %s %v %s %s\n", fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipaddr)) nsupdate.write("send\n") From eb88b8bc893077dbacde304d06f6f68eb831fde8 Mon Sep 17 00:00:00 2001 From: Golit Date: Sat, 29 Aug 2020 17:39:29 +0200 Subject: [PATCH 11/15] Replace error message variables * It is more usefull to have the user input value in the error message --- rest-api/request_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 4615aca..0558308 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -80,8 +80,8 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr if len(response.Addresses) == 0 { response.Success = false - response.Message = fmt.Sprintf("%v is neither a valid IPv4 nor IPv6 address", response.Addresses) - log.Println(fmt.Sprintf("Invalid address: %v", response.Addresses)) + response.Message = fmt.Sprintf("%v is neither a valid IPv4 nor IPv6 address", extractors.Address(r)) + log.Println(fmt.Sprintf("Invalid address: %v", extractors.Address(r))) return response } From 00fba1789d703697dcf8b76ad517e454a4fa5ee8 Mon Sep 17 00:00:00 2001 From: Golit Date: Mon, 31 Aug 2020 16:31:09 +0200 Subject: [PATCH 12/15] Change sharedSecred checking method The rest-api does not need to check the shared secret because bind itself can check it. This change also allows to have different shared secrets for different zones. See #55 --- Dockerfile | 2 +- rest-api/config.go | 9 ------ rest-api/main.go | 10 +++---- rest-api/nsupdate.go | 19 +++++++----- rest-api/request_handler.go | 5 ++-- rest-api/request_handler_test.go | 12 -------- rest-api/utils.go | 17 ----------- setup.sh | 51 +++++++++++++++++++++++--------- 8 files changed, 56 insertions(+), 69 deletions(-) delete mode 100644 rest-api/utils.go diff --git a/Dockerfile b/Dockerfile index 0244847..a26347a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ FROM debian:buster-slim MAINTAINER David Prandzioch RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ - apt-get install -q -y bind9 dnsutils && \ + apt-get install -q -y bind9 dnsutils openssl && \ apt-get clean RUN chmod 770 /var/cache/bind diff --git a/rest-api/config.go b/rest-api/config.go index e7d7bf4..8cfc7a0 100644 --- a/rest-api/config.go +++ b/rest-api/config.go @@ -7,10 +7,8 @@ import ( ) type Config struct { - SharedSecret string Server string Zone string - Domain string NsupdateBinary string RecordTTL int Port int @@ -38,10 +36,8 @@ func (conf *Config) loadConfigFromFile(path string) { func (flagsConf *ConfigFlags) setupFlags() { flag.BoolVar(&flagsConf.DoNotLoadConfig, "noConfig", false, "Do not load the config file") flag.StringVar(&flagsConf.ConfigFile, "c", "/etc/dyndns.json", "The configuration file") - flag.StringVar(&flagsConf.SharedSecret, "sharedSecret", "", "The shared secret (default a generated random string)") flag.StringVar(&flagsConf.Server, "server", "localhost", "The address of the bind server") flag.StringVar(&flagsConf.Zone, "zone", "localhost", "Zone") - flag.StringVar(&flagsConf.Domain, "domain", "localhost", "Domain") flag.StringVar(&flagsConf.NsupdateBinary, "nsupdateBinary", "nsupdate", "Path to nsupdate program") flag.IntVar(&flagsConf.RecordTTL, "recordTTL", 300, "RecordTTL") flag.IntVar(&flagsConf.Port, "p", 8080, "Port") @@ -58,9 +54,4 @@ func (flagsConf *ConfigFlags) LoadConfig() { flagsConf.loadConfigFromFile(flagsConf.ConfigFile) flag.Parse() // Parse a second time to overwrite settings from the loaded file } - - // Fix unsafe config values - if flagsConf.SharedSecret == "" { - flagsConf.SharedSecret = randomString() - } } diff --git a/rest-api/main.go b/rest-api/main.go index f0a7126..9cba633 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -86,10 +86,10 @@ func updateDomains(r *http.Request, response *WebserviceResponse, onError func() for _, address := range response.Addresses { for _, domain := range response.Domains { recordUpdate := RecordUpdateRequest{ - domain: domain, - ipaddr: address.Address, - addrType: address.AddrType, - ddnskey: extractor.DdnsKey(r), + domain: domain, + ipAddr: address.Address, + addrType: address.AddrType, + secret: extractor.Secret(r), } result := recordUpdate.updateRecord() @@ -122,7 +122,7 @@ func (r RecordUpdateRequest) updateRecord() string { status = "failed, error: " + result } - log.Println(fmt.Sprintf("%s record update request: %s -> %s %s", r.addrType, r.domain, r.ipaddr, status)) + log.Println(fmt.Sprintf("%s record update request: %s -> %s %s", r.addrType, r.domain, r.ipAddr, status)) return result } diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go index fd4169c..c5f13e8 100644 --- a/rest-api/nsupdate.go +++ b/rest-api/nsupdate.go @@ -19,10 +19,13 @@ type NSUpdateInterface interface { // RecordUpdateRequest data representing a update request type RecordUpdateRequest struct { - domain string - ipaddr string - addrType string - ddnskey string + domain string + ipAddr string + addrType string + ddnsKeyName string + secret string + zone string + fqdn string } // NSUpdate holds resources need for an open nsupdate program @@ -106,9 +109,9 @@ func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { fqdn = escape(r.domain) + "." + appConfig.Zone } - if r.ddnskey != "" { - fqdnN := strings.TrimLeft(fqdn, ".") - nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", fqdnN, escape(r.ddnskey)) + if r.secret != "" { + fqdnN := strings.TrimLeft(appConfig.Zone, ".") + nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", fqdnN, escape(r.secret)) } nsupdate.write("server %s\n", appConfig.Server) @@ -116,6 +119,6 @@ func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { nsupdate.write("zone %s\n", appConfig.Zone) } nsupdate.write("update delete %s %s\n", fqdn, r.addrType) - nsupdate.write("update add %s %v %s %s\n", fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipaddr)) + nsupdate.write("update add %s %v %s %s\n", fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipAddr)) nsupdate.write("send\n") } diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 0558308..8ee6b52 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -39,7 +39,6 @@ func ParseAddress(address string) (Address, error) { func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors requestDataExtractor) WebserviceResponse { response := WebserviceResponse{} - sharedSecret := extractors.Secret(r) response.Domains = strings.Split(extractors.Domain(r), ",") for _, address := range strings.Split(extractors.Address(r), ",") { var parsedAddress, error = ParseAddress(address) @@ -48,8 +47,8 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr } } - if sharedSecret != appConfig.SharedSecret { - log.Println(fmt.Sprintf("Invalid shared secret: %s", sharedSecret)) + if extractors.Secret(r) == "" { // futher checking is done by bind server as configured + log.Println(fmt.Sprintf("Invalid shared secret")) response.Success = false response.Message = "Invalid Credentials" return response diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index e248ae3..d01aca9 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -11,7 +11,6 @@ var dynExtractor = dynRequestDataExtractor{} func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo&addr=1.2.3.4", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -35,7 +34,6 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { func TestBuildWebserviceResponseFromRequestWithXRealIPHeaderToReturnValidObject(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo", nil) req.Header.Add("X-Real-Ip", "1.2.3.4") @@ -60,7 +58,6 @@ func TestBuildWebserviceResponseFromRequestWithXRealIPHeaderToReturnValidObject( func TestBuildWebserviceResponseFromRequestWithXForwardedForHeaderToReturnValidObject(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo", nil) req.Header.Add("X-Forwarded-For", "1.2.3.4") @@ -85,7 +82,6 @@ func TestBuildWebserviceResponseFromRequestWithXForwardedForHeaderToReturnValidO func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -97,7 +93,6 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGi func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidSecretIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=foo", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -109,7 +104,6 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidSecre func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoDomainIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -121,7 +115,6 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoDomainIsGi func TestBuildWebserviceResponseFromRequestWithMultipleDomains(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo,bar&addr=1.2.3.4", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -145,7 +138,6 @@ func TestBuildWebserviceResponseFromRequestWithMultipleDomains(t *testing.T) { func TestBuildWebserviceResponseFromRequestWithMalformedMultipleDomains(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo,&addr=1.2.3.4", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -157,7 +149,6 @@ func TestBuildWebserviceResponseFromRequestWithMalformedMultipleDomains(t *testi func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoAddressIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("POST", "/update?secret=changeme&domain=foo", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -169,7 +160,6 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoAddressIsG func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidAddressIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo&addr=1.41:2", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -181,7 +171,6 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenInvalidAddre 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 "+base64.StdEncoding.EncodeToString([]byte("username:changeme"))) @@ -207,7 +196,6 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObjectWithDynExtractor(t func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGivenWithDynExtractor(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/nic/update", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, dynExtractor) diff --git a/rest-api/utils.go b/rest-api/utils.go deleted file mode 100644 index 62bd2df..0000000 --- a/rest-api/utils.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "fmt" - "crypto/rand" - "encoding/base64" -) - -func randomString() string { - random := make([]byte, 32) - _, err := rand.Read(random) - if err != nil { - fmt.Println("error:", err) - return "8Passw0RT!" - } - return base64.StdEncoding.EncodeToString(random) -} diff --git a/setup.sh b/setup.sh index 2377c44..5ab1fa7 100755 --- a/setup.sh +++ b/setup.sh @@ -1,41 +1,66 @@ #!/bin/bash +NAMED_HOST=${NAMED_HOST:-'localhost'} +ZONES=$(echo $ZONE | tr ',' '\n') +RECORD_TTL=${RECORD_TTL:-300} -[ -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; +# Backward compatibility for a single zone +if [ $(echo "$ZONES" | wc -l) -eq 1 ]; then + # Allow update without fqdn + ZONE=$(echo "$ZONES" | head -1) +else + # Allow multiple zones and disable updates without fqdn + ZONE="" +fi + +# replaces a config value +function bind-conf-set { + local KEY=${1:?'No key set'} + local SECRED=${2:-$(openssl rand 32 | base64)} + sed -E 's@('"${KEY}"')(\W+)"(.*)"@\1 "'${SECRED}'"@g' /dev/stdin +} +function bind-zone-add { +local ZONE=${1:?'No zone set'} if ! grep 'zone "'$ZONE'"' /etc/bind/named.conf > /dev/null then - echo "creating zone..."; + echo "creating zone for $ZONE..."; cat >> /etc/bind/named.conf < /var/cache/bind/$ZONE.zone < /etc/dyndns.json < Date: Mon, 31 Aug 2020 17:27:57 +0200 Subject: [PATCH 13/15] Add support for multiple zones * Fix #7 #18 by allowing updates by fqdn --- rest-api/config.go | 2 +- rest-api/main.go | 7 +- rest-api/nsupdate.go | 21 ++---- rest-api/request_data_extractor.go | 36 ++++++++-- rest-api/request_data_extractor_test.go | 95 +++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 rest-api/request_data_extractor_test.go diff --git a/rest-api/config.go b/rest-api/config.go index 8cfc7a0..b730699 100644 --- a/rest-api/config.go +++ b/rest-api/config.go @@ -37,7 +37,7 @@ func (flagsConf *ConfigFlags) setupFlags() { flag.BoolVar(&flagsConf.DoNotLoadConfig, "noConfig", false, "Do not load the config file") flag.StringVar(&flagsConf.ConfigFile, "c", "/etc/dyndns.json", "The configuration file") flag.StringVar(&flagsConf.Server, "server", "localhost", "The address of the bind server") - flag.StringVar(&flagsConf.Zone, "zone", "localhost", "Zone") + flag.StringVar(&flagsConf.Zone, "zone", "", "Configuring a default zone will allow to send request with the hostname only as the domain") flag.StringVar(&flagsConf.NsupdateBinary, "nsupdateBinary", "nsupdate", "Path to nsupdate program") flag.IntVar(&flagsConf.RecordTTL, "recordTTL", 300, "RecordTTL") flag.IntVar(&flagsConf.Port, "p", 8080, "Port") diff --git a/rest-api/main.go b/rest-api/main.go index 9cba633..692471d 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -19,8 +19,8 @@ const ( var appConfig = &ConfigFlags{} func main() { - defaultExtractor := defaultRequestDataExtractor{} - dynExtractor := dynRequestDataExtractor{} + defaultExtractor := defaultRequestDataExtractor{appConfig: &appConfig.Config} + dynExtractor := dynRequestDataExtractor{defaultRequestDataExtractor{appConfig: &appConfig.Config}} appConfig.LoadConfig() @@ -90,6 +90,9 @@ func updateDomains(r *http.Request, response *WebserviceResponse, onError func() ipAddr: address.Address, addrType: address.AddrType, secret: extractor.Secret(r), + ddnsKeyName: extractor.DdnsKeyName(r, domain), + zone: extractor.Zone(r, domain), + fqdn: extractor.Fqdn(r, domain), } result := recordUpdate.updateRecord() diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go index c5f13e8..2b52a0c 100644 --- a/rest-api/nsupdate.go +++ b/rest-api/nsupdate.go @@ -104,21 +104,14 @@ func escape(s string) string { // UpdateRecord sends the record update request to the nsupdate program func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { - fqdn := escape(r.domain) - if appConfig.Zone != "" { - fqdn = escape(r.domain) + "." + appConfig.Zone + nsupdate.write("server %s\n", appConfig.Server) + if r.zone != "" { + nsupdate.write("zone %s\n", r.zone+".") } - - if r.secret != "" { - fqdnN := strings.TrimLeft(appConfig.Zone, ".") - nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", fqdnN, escape(r.secret)) + if r.ddnsKeyName != "" { + nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", escape(r.ddnsKeyName), escape(r.secret)) } - - nsupdate.write("server %s\n", appConfig.Server) - if appConfig.Zone != "" { - nsupdate.write("zone %s\n", appConfig.Zone) - } - nsupdate.write("update delete %s %s\n", fqdn, r.addrType) - nsupdate.write("update add %s %v %s %s\n", fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipAddr)) + nsupdate.write("update delete %s %s\n", r.fqdn, r.addrType) + nsupdate.write("update add %s %v %s %s\n", r.fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipAddr)) nsupdate.write("send\n") } diff --git a/rest-api/request_data_extractor.go b/rest-api/request_data_extractor.go index c743976..70b4c75 100644 --- a/rest-api/request_data_extractor.go +++ b/rest-api/request_data_extractor.go @@ -3,16 +3,21 @@ package main import ( "context" "net/http" + "strings" ) type requestDataExtractor interface { Address(r *http.Request) string Secret(r *http.Request) string Domain(r *http.Request) string - DdnsKey(r *http.Request) string + DdnsKeyName(r *http.Request, domain string) string + Zone(r *http.Request, domain string) string + Fqdn(r *http.Request, domain string) string } -type defaultRequestDataExtractor struct{} +type defaultRequestDataExtractor struct { + appConfig *Config +} func (e defaultRequestDataExtractor) Address(r *http.Request) string { return r.URL.Query().Get("addr") @@ -23,8 +28,31 @@ func (e defaultRequestDataExtractor) Secret(r *http.Request) string { func (e defaultRequestDataExtractor) Domain(r *http.Request) string { return r.URL.Query().Get("domain") } -func (e defaultRequestDataExtractor) DdnsKey(r *http.Request) string { - return r.URL.Query().Get("ddnskey") +func (e defaultRequestDataExtractor) DdnsKeyName(r *http.Request, domain string) string { + ddnsKeyName := r.URL.Query().Get("ddnskeyname") + if ddnsKeyName != "" { + return ddnsKeyName + } + ddnsKeyName = e.Zone(r, domain) + if ddnsKeyName != "" { + return ddnsKeyName + } + ddnsKeyName = e.Fqdn(r, domain) + return ddnsKeyName +} +func (e defaultRequestDataExtractor) Zone(r *http.Request, domain string) string { + zone := r.URL.Query().Get("zone") + if zone != "" { + return zone + } + zone = strings.TrimRight(e.appConfig.Zone, ".") + if domain[len(domain)-1:] == "." { + zone = "" + } + return zone +} +func (e defaultRequestDataExtractor) Fqdn(r *http.Request, domain string) string { + return strings.TrimRight(escape(domain)+"."+e.Zone(r, domain), ".") } type dynRequestDataExtractor struct{ defaultRequestDataExtractor } diff --git a/rest-api/request_data_extractor_test.go b/rest-api/request_data_extractor_test.go new file mode 100644 index 0000000..d9f14b6 --- /dev/null +++ b/rest-api/request_data_extractor_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "net/http" + "testing" +) + +func verify(t *testing.T, r *http.Request, extractor requestDataExtractor, domain string, expected RecordUpdateRequest) { + rru := RecordUpdateRequest{ + ddnsKeyName: extractor.DdnsKeyName(r, domain), + zone: extractor.Zone(r, domain), + fqdn: extractor.Fqdn(r, domain), + } + if rru.zone != expected.zone { + t.Fatalf("Zone not configured but not empty: %s != %s", rru.zone, expected.zone) + } + + if rru.fqdn != expected.fqdn { + t.Fatalf("Wrong fqdn: %s != %s", rru.fqdn, expected.fqdn) + } + + if rru.ddnsKeyName != expected.ddnsKeyName { + t.Fatalf("Wrong ddnskeyname: %s != %s", rru.ddnsKeyName, expected.ddnsKeyName) + } +} + +func TestExtractorUnconfiguredZone(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "", + }} + + domain := "foo.example.org" + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain="+domain+"&addr=1.2.3.4", nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "", + fqdn: "foo.example.org", + ddnsKeyName: "foo.example.org", + }) +} + +func TestExtractorUnconfiguredZoneWithZoneInRequest(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "", + }} + + domain := "foo" + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain="+domain+"&addr=1.2.3.4&zone=example.org", nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "example.org", + fqdn: "foo.example.org", + ddnsKeyName: "example.org", + }) +} + +func TestExtractorUnconfiguredZoneWithDDnskeyInRequest(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "", + }} + + domain := "foo.example.org" + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain="+domain+"&addr=1.2.3.4&ddnskeyname=example.org", nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "", + fqdn: "foo.example.org", + ddnsKeyName: "example.org", + }) +} + +func TestExtractorConfiguredZoneAndOnlyWithHostname(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "example.org.", + }} + + domain := "foo" + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain="+domain+"&addr=1.2.3.4", nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "example.org", + fqdn: "foo.example.org", + ddnsKeyName: "example.org", + }) +} + +func TestExtractorConfiguredZoneAndOnlyWithFQDN(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "example.org.", + }} + + domain := "foo.example.org." + req, _ := http.NewRequest("GET", "/update?secret=changeme&domain="+domain+"&addr=1.2.3.4", nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "", + fqdn: "foo.example.org", + ddnsKeyName: "foo.example.org", + }) +} From 5300a3e7dddff6093cb2d4e3c78a8cd44329d9d5 Mon Sep 17 00:00:00 2001 From: Golit Date: Sat, 26 Mar 2022 03:13:53 +0100 Subject: [PATCH 14/15] Add support to update any record type * Any record type can be updated by passing query parameter 'type' and 'value' or 'addr' as a fallback * Some code cleanup Closes #49 Closes #61 --- rest-api/main.go | 12 +++--- rest-api/request_data_extractor.go | 12 ++++++ rest-api/request_handler.go | 63 ++++++++++++++++++------------ rest-api/request_handler_test.go | 24 ++++++------ 4 files changed, 68 insertions(+), 43 deletions(-) diff --git a/rest-api/main.go b/rest-api/main.go index 692471d..67720da 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -41,7 +41,7 @@ func main() { func dynUpdate(w http.ResponseWriter, r *http.Request) { response := r.Context().Value(responseKey).(WebserviceResponse) - if response.Success == false { + if !response.Success { if response.Message == "Domain not set" { w.Write([]byte("notfqdn\n")) } else { @@ -64,7 +64,7 @@ func dynUpdate(w http.ResponseWriter, r *http.Request) { func update(w http.ResponseWriter, r *http.Request) { response := r.Context().Value(responseKey).(WebserviceResponse) - if response.Success == false { + if !response.Success { json.NewEncoder(w).Encode(response) return } @@ -83,12 +83,12 @@ func update(w http.ResponseWriter, r *http.Request) { func updateDomains(r *http.Request, response *WebserviceResponse, onError func()) bool { extractor := r.Context().Value(extractorKey).(requestDataExtractor) - for _, address := range response.Addresses { + for _, record := range response.Records { for _, domain := range response.Domains { recordUpdate := RecordUpdateRequest{ domain: domain, - ipAddr: address.Address, - addrType: address.AddrType, + ipAddr: record.Value, + addrType: record.Type, secret: extractor.Secret(r), ddnsKeyName: extractor.DdnsKeyName(r, domain), zone: extractor.Zone(r, domain), @@ -108,7 +108,7 @@ func updateDomains(r *http.Request, response *WebserviceResponse, onError func() if len(response.Message) != 0 { response.Message += "; " } - response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", address.AddrType, domain, address.Address) + response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", record.Type, domain, record.Value) } } diff --git a/rest-api/request_data_extractor.go b/rest-api/request_data_extractor.go index 70b4c75..fb27e80 100644 --- a/rest-api/request_data_extractor.go +++ b/rest-api/request_data_extractor.go @@ -10,6 +10,8 @@ type requestDataExtractor interface { Address(r *http.Request) string Secret(r *http.Request) string Domain(r *http.Request) string + Type(r *http.Request) string + Value(r *http.Request) string DdnsKeyName(r *http.Request, domain string) string Zone(r *http.Request, domain string) string Fqdn(r *http.Request, domain string) string @@ -28,6 +30,16 @@ func (e defaultRequestDataExtractor) Secret(r *http.Request) string { func (e defaultRequestDataExtractor) Domain(r *http.Request) string { return r.URL.Query().Get("domain") } +func (e defaultRequestDataExtractor) Type(r *http.Request) string { + return r.URL.Query().Get("type") +} +func (e defaultRequestDataExtractor) Value(r *http.Request) string { + value := r.URL.Query().Get("value") + if value == "" { + value = e.Address(r) + } + return value +} func (e defaultRequestDataExtractor) DdnsKeyName(r *http.Request, domain string) string { ddnsKeyName := r.URL.Query().Get("ddnskeyname") if ddnsKeyName != "" { diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 8ee6b52..98df385 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -13,27 +13,27 @@ import ( ) type WebserviceResponse struct { - Success bool - Message string - Domain string - Domains []string - Address string - AddrType string - Addresses []Address -} - -type Address struct { + Success bool + Message string + Domain string + Domains []string Address string AddrType string + Records []Record } -func ParseAddress(address string) (Address, error) { +type Record struct { + Value string + Type string +} + +func ParseAddress(address string) (Record, error) { if ipparser.ValidIP4(address) { - return Address{Address: address, AddrType: "A"}, nil + return Record{Value: address, Type: "A"}, nil } else if ipparser.ValidIP6(address) { - return Address{Address: address, AddrType: "AAAA"}, nil + return Record{Value: address, Type: "AAAA"}, nil } - return Address{}, fmt.Errorf("Invalid ip address: %s", address) + return Record{}, fmt.Errorf("invalid ip address: %s", address) } func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors requestDataExtractor) WebserviceResponse { @@ -41,29 +41,42 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr response.Domains = strings.Split(extractors.Domain(r), ",") for _, address := range strings.Split(extractors.Address(r), ",") { + if address == "" { + continue + } var parsedAddress, error = ParseAddress(address) if error == nil { - response.Addresses = append(response.Addresses, parsedAddress) + response.Records = append(response.Records, parsedAddress) + } else { + response.Success = false + response.Message = fmt.Sprintf("Error: %v. '%v' is neither a valid IPv4 nor IPv6 address", error, extractors.Address(r)) + log.Println(response.Message) + return response } } if extractors.Secret(r) == "" { // futher checking is done by bind server as configured - log.Println(fmt.Sprintf("Invalid shared secret")) response.Success = false response.Message = "Invalid Credentials" + log.Println(response.Message) return response } for _, domain := range response.Domains { if domain == "" { response.Success = false - response.Message = fmt.Sprintf("Domain not set") - log.Println("Domain not set") + response.Message = "Domain not set" + log.Println(response.Message) return response } } - if len(response.Addresses) == 0 { + req := Record{extractors.Value(r), extractors.Type(r)} + if req.Type != "" && req.Value != "" { + response.Records = append(response.Records, req) + } + + if len(response.Records) == 0 { ip, err := getUserIP(r) if ip == "" { ip, _, err = net.SplitHostPort(r.RemoteAddr) @@ -72,22 +85,22 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr if err == nil { parsedAddress, err := ParseAddress(ip) if err == nil { - response.Addresses = append(response.Addresses, parsedAddress) + response.Records = append(response.Records, parsedAddress) } } } - if len(response.Addresses) == 0 { + if len(response.Records) == 0 { response.Success = false - response.Message = fmt.Sprintf("%v is neither a valid IPv4 nor IPv6 address", extractors.Address(r)) - log.Println(fmt.Sprintf("Invalid address: %v", extractors.Address(r))) + response.Message = "No valid update data could be extracted from request" + log.Println(response.Message) return response } // kept in the response for compatibility reasons response.Domain = strings.Join(response.Domains, ",") - response.Address = response.Addresses[0].Address - response.AddrType = response.Addresses[0].AddrType + response.Address = response.Records[0].Value + response.AddrType = response.Records[0].Type response.Success = true diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index d01aca9..b8fbb9c 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -23,11 +23,11 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.Addresses[0].Address != "1.2.3.4" { + if result.Records[0].Value != "1.2.3.4" { t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") } - if result.Addresses[0].AddrType != "A" { + if result.Records[0].Type != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } @@ -47,11 +47,11 @@ func TestBuildWebserviceResponseFromRequestWithXRealIPHeaderToReturnValidObject( t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.Addresses[0].Address != "1.2.3.4" { + if result.Records[0].Value != "1.2.3.4" { t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4") } - if result.Addresses[0].AddrType != "A" { + if result.Records[0].Type != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } @@ -71,12 +71,12 @@ func TestBuildWebserviceResponseFromRequestWithXForwardedForHeaderToReturnValidO t.Fatalf("Expected WebserviceResponse.Domain to be foo but was %s", result.Domain) } - if result.Addresses[0].Address != "1.2.3.4" { - t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Addresses[0].Address) + if result.Records[0].Value != "1.2.3.4" { + t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Records[0].Value) } - if result.Addresses[0].AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Addresses[0].AddrType) + if result.Records[0].Type != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Records[0].Type) } } @@ -185,12 +185,12 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObjectWithDynExtractor(t t.Fatalf("Expected WebserviceResponse.Domain to be foo but was %s", result.Domain) } - if result.Addresses[0].Address != "1.2.3.4" { - t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Addresses[0].Address) + if result.Records[0].Value != "1.2.3.4" { + t.Fatalf("Expected WebserviceResponse.Address to be 1.2.3.4 but was %s", result.Records[0].Value) } - if result.Addresses[0].AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Addresses[0].AddrType) + if result.Records[0].Type != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Records[0].Type) } } From f861fef8bf92dadc81c5b6746f273b93c84d54b6 Mon Sep 17 00:00:00 2001 From: Golit Date: Sat, 26 Mar 2022 23:49:05 +0100 Subject: [PATCH 15/15] Add delete endpoint --- rest-api/main.go | 43 +++++++++++++++---------- rest-api/nsupdate.go | 41 +++++++++++++++++++++-- rest-api/request_data_extractor.go | 7 ++++ rest-api/request_data_extractor_test.go | 34 +++++++++++++++++++ rest-api/request_handler.go | 23 ++++++------- rest-api/request_handler_test.go | 11 +++++++ 6 files changed, 129 insertions(+), 30 deletions(-) diff --git a/rest-api/main.go b/rest-api/main.go index 67720da..9cc81aa 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -25,12 +25,13 @@ func main() { appConfig.LoadConfig() router := mux.NewRouter().StrictSlash(true) - router.Handle("/update", requestRequestDataMiddleware(http.HandlerFunc(update), defaultExtractor)).Methods("GET") + router.Handle("/update", requestRequestDataMiddleware(http.HandlerFunc(update), defaultExtractor)).Methods(http.MethodGet) + router.Handle("/delete", requestRequestDataMiddleware(http.HandlerFunc(update), defaultExtractor)).Methods(http.MethodGet, http.MethodDelete) /* DynDNS compatible handlers. Most routers will invoke /nic/update */ - router.Handle("/nic/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") - router.Handle("/v2/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") - router.Handle("/v3/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods("GET") + router.Handle("/nic/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods(http.MethodGet) + router.Handle("/v2/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods(http.MethodGet) + router.Handle("/v3/update", requestRequestDataMiddleware(http.HandlerFunc(dynUpdate), dynExtractor)).Methods(http.MethodGet) listenTo := fmt.Sprintf("%s:%d", "", appConfig.Port) @@ -93,39 +94,47 @@ func updateDomains(r *http.Request, response *WebserviceResponse, onError func() ddnsKeyName: extractor.DdnsKeyName(r, domain), zone: extractor.Zone(r, domain), fqdn: extractor.Fqdn(r, domain), + action: extractor.Action(r), } - result := recordUpdate.updateRecord() + result, err := recordUpdate.updateRecord() - if result != "" { + if err != nil { response.Success = false - response.Message = result + response.Message = err.Error() onError() return false } - response.Success = true if len(response.Message) != 0 { response.Message += "; " } - response.Message += fmt.Sprintf("Updated %s record for %s to IP address %s", record.Type, domain, record.Value) + response.Message += result } } return true } -func (r RecordUpdateRequest) updateRecord() string { +func (r RecordUpdateRequest) updateRecord() (string, error) { var nsupdate NSUpdateInterface = NewNSUpdate() - nsupdate.UpdateRecord(r) + message := "No action executed" + switch r.action { + case UpdateRequestActionDelete: + nsupdate.DeleteRecord(r) + message = fmt.Sprintf("Deleted %s record for %s", r.addrType, r.domain) + case UpdateRequestActionUpdate: + fallthrough + default: + nsupdate.UpdateRecord(r) + message = fmt.Sprintf("Updated %s record: %s -> %s", r.addrType, r.domain, r.ipAddr) + } result := nsupdate.Close() - status := "succeeded" + log.Println(message) + if result != "" { - status = "failed, error: " + result + return "", fmt.Errorf("%s", result) } - - log.Println(fmt.Sprintf("%s record update request: %s -> %s %s", r.addrType, r.domain, r.ipAddr, status)) - - return result + return message, nil } diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go index 2b52a0c..16d3e93 100644 --- a/rest-api/nsupdate.go +++ b/rest-api/nsupdate.go @@ -14,9 +14,17 @@ import ( // NSUpdateInterface is the interface to a client which can update a DNS record type NSUpdateInterface interface { UpdateRecord(r RecordUpdateRequest) + DeleteRecord(r RecordUpdateRequest) Close() string } +type UpdateRequestAction int + +const ( + UpdateRequestActionUpdate UpdateRequestAction = iota + UpdateRequestActionDelete +) + // RecordUpdateRequest data representing a update request type RecordUpdateRequest struct { domain string @@ -26,6 +34,7 @@ type RecordUpdateRequest struct { secret string zone string fqdn string + action UpdateRequestAction } // NSUpdate holds resources need for an open nsupdate program @@ -35,6 +44,8 @@ type NSUpdate struct { stdinPipe io.WriteCloser out bytes.Buffer stderr bytes.Buffer + authSent bool + needSend bool } // NewNSUpdate starts the nsupdate program @@ -58,6 +69,8 @@ func NewNSUpdate() *NSUpdate { return nil } nsupdate.w = bufio.NewWriter(nsupdate.stdinPipe) + nsupdate.authSent = false + nsupdate.needSend = false return nsupdate } @@ -75,6 +88,10 @@ func (nsupdate *NSUpdate) write(format string, a ...interface{}) { func (nsupdate *NSUpdate) Close() string { var err error + if nsupdate.needSend { + nsupdate.Send() + } + nsupdate.write("quit\n") nsupdate.w.Flush() nsupdate.stdinPipe.Close() @@ -104,6 +121,24 @@ func escape(s string) string { // UpdateRecord sends the record update request to the nsupdate program func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { + nsupdate.Auth(r) + nsupdate.DeleteRecord(r) + nsupdate.write("update add %s %v %s %s\n", r.fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipAddr)) + nsupdate.needSend = true +} + +// DeleteRecord sends the record delete request to the nsupdate program +func (nsupdate *NSUpdate) DeleteRecord(r RecordUpdateRequest) { + nsupdate.Auth(r) + nsupdate.write("update delete %s %s\n", r.fqdn, escape(r.addrType)) + nsupdate.needSend = true +} + +// DeleteRecord sends auth if needed +func (nsupdate *NSUpdate) Auth(r RecordUpdateRequest) { + if nsupdate.authSent { + return + } nsupdate.write("server %s\n", appConfig.Server) if r.zone != "" { nsupdate.write("zone %s\n", r.zone+".") @@ -111,7 +146,9 @@ func (nsupdate *NSUpdate) UpdateRecord(r RecordUpdateRequest) { if r.ddnsKeyName != "" { nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", escape(r.ddnsKeyName), escape(r.secret)) } - nsupdate.write("update delete %s %s\n", r.fqdn, r.addrType) - nsupdate.write("update add %s %v %s %s\n", r.fqdn, appConfig.RecordTTL, r.addrType, escape(r.ipAddr)) + nsupdate.authSent = true +} + +func (nsupdate *NSUpdate) Send() { nsupdate.write("send\n") } diff --git a/rest-api/request_data_extractor.go b/rest-api/request_data_extractor.go index fb27e80..092e125 100644 --- a/rest-api/request_data_extractor.go +++ b/rest-api/request_data_extractor.go @@ -15,6 +15,7 @@ type requestDataExtractor interface { DdnsKeyName(r *http.Request, domain string) string Zone(r *http.Request, domain string) string Fqdn(r *http.Request, domain string) string + Action(r *http.Request) UpdateRequestAction } type defaultRequestDataExtractor struct { @@ -40,6 +41,12 @@ func (e defaultRequestDataExtractor) Value(r *http.Request) string { } return value } +func (e defaultRequestDataExtractor) Action(r *http.Request) UpdateRequestAction { + if r.URL.Path == "/delete" || r.Method == http.MethodDelete { + return UpdateRequestActionDelete + } + return UpdateRequestActionUpdate +} func (e defaultRequestDataExtractor) DdnsKeyName(r *http.Request, domain string) string { ddnsKeyName := r.URL.Query().Get("ddnskeyname") if ddnsKeyName != "" { diff --git a/rest-api/request_data_extractor_test.go b/rest-api/request_data_extractor_test.go index d9f14b6..3f91550 100644 --- a/rest-api/request_data_extractor_test.go +++ b/rest-api/request_data_extractor_test.go @@ -10,6 +10,7 @@ func verify(t *testing.T, r *http.Request, extractor requestDataExtractor, domai ddnsKeyName: extractor.DdnsKeyName(r, domain), zone: extractor.Zone(r, domain), fqdn: extractor.Fqdn(r, domain), + action: extractor.Action(r), } if rru.zone != expected.zone { t.Fatalf("Zone not configured but not empty: %s != %s", rru.zone, expected.zone) @@ -22,6 +23,9 @@ func verify(t *testing.T, r *http.Request, extractor requestDataExtractor, domai if rru.ddnsKeyName != expected.ddnsKeyName { t.Fatalf("Wrong ddnskeyname: %s != %s", rru.ddnsKeyName, expected.ddnsKeyName) } + if rru.action != expected.action { + t.Fatalf("Wrong action: %v != %v", rru.action, expected.action) + } } func TestExtractorUnconfiguredZone(t *testing.T) { @@ -93,3 +97,33 @@ func TestExtractorConfiguredZoneAndOnlyWithFQDN(t *testing.T) { ddnsKeyName: "foo.example.org", }) } + +func TestExtractorURLDelete(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "example.org.", + }} + + domain := "foo" + req, _ := http.NewRequest("GET", "/delete?secret=changeme&domain="+domain, nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "example.org", + fqdn: "foo.example.org", + ddnsKeyName: "example.org", + action: UpdateRequestActionDelete, + }) +} + +func TestExtractorMethodDelete(t *testing.T) { + var e = defaultRequestDataExtractor{appConfig: &Config{ + Zone: "example.org.", + }} + + domain := "foo" + req, _ := http.NewRequest(http.MethodDelete, "/delete?secret=changeme&domain="+domain, nil) + verify(t, req, e, domain, RecordUpdateRequest{ + zone: "example.org", + fqdn: "foo.example.org", + ddnsKeyName: "example.org", + action: UpdateRequestActionDelete, + }) +} diff --git a/rest-api/request_handler.go b/rest-api/request_handler.go index 98df385..b6dd488 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -90,18 +90,19 @@ func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extr } } - if len(response.Records) == 0 { - response.Success = false - response.Message = "No valid update data could be extracted from request" - log.Println(response.Message) - return response - } - - // kept in the response for compatibility reasons - response.Domain = strings.Join(response.Domains, ",") - response.Address = response.Records[0].Value - response.AddrType = response.Records[0].Type + if extractors.Action(r) == UpdateRequestActionUpdate { + if len(response.Records) == 0 { + response.Success = false + response.Message = "No valid update data could be extracted from request" + log.Println(response.Message) + return response + } + // kept in the response for compatibility reasons + response.Domain = strings.Join(response.Domains, ",") + response.Address = response.Records[0].Value + response.AddrType = response.Records[0].Type + } response.Success = true return response diff --git a/rest-api/request_handler_test.go b/rest-api/request_handler_test.go index b8fbb9c..570ee16 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -204,3 +204,14 @@ func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGi t.Fatalf("Expected WebserviceResponse.Success to be false") } } + +func TestBuildWebserviceResponseFromRequestToDeleteSuccess(t *testing.T) { + var appConfig = &Config{} + + req, _ := http.NewRequest(http.MethodGet, "/delete?secret=changeme&domain=foo", nil) + result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) + + if !result.Success { + t.Fatalf("Expected /delete request to succeed") + } +}