diff --git a/Dockerfile b/Dockerfile index dea5495..a26347a 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 @@ -12,14 +9,14 @@ 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 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" diff --git a/rest-api/config.go b/rest-api/config.go index 4a591b4..b730699 100644 --- a/rest-api/config.go +++ b/rest-api/config.go @@ -2,19 +2,26 @@ package main import ( "encoding/json" + "flag" "os" ) type Config struct { - SharedSecret string Server string Zone string - 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 +32,26 @@ 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.Server, "server", "localhost", "The address of the bind server") + 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") + 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 + } +} 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/main.go b/rest-api/main.go index d556ad4..9cc81aa 100644 --- a/rest-api/main.go +++ b/rest-api/main.go @@ -1,52 +1,48 @@ package main import ( - "bufio" - "bytes" "encoding/json" "fmt" - "io/ioutil" "log" "net/http" - "os" - "os/exec" "github.com/gorilla/mux" ) -var appConfig = &Config{} +type key int + +const ( + responseKey key = iota + extractorKey key = iota +) + +var appConfig = &ConfigFlags{} func main() { - appConfig.LoadConfig("/etc/dyndns.json") + defaultExtractor := defaultRequestDataExtractor{appConfig: &appConfig.Config} + dynExtractor := dynRequestDataExtractor{defaultRequestDataExtractor{appConfig: &appConfig.Config}} + + appConfig.LoadConfig() router := mux.NewRouter().StrictSlash(true) - router.HandleFunc("/update", Update).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.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(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) - 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) -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") - } + log.Println(fmt.Sprintf("Serving dyndns REST services on " + listenTo + "...")) + log.Fatal(http.ListenAndServe(listenTo, router)) +} - return sharedSecret - }, - Domain: func(r *http.Request) string { return r.URL.Query().Get("hostname") }, - } - response := BuildWebserviceResponseFromRequest(r, appConfig, extractor) +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 { @@ -55,84 +51,90 @@ func DynUpdate(w http.ResponseWriter, r *http.Request) { return } - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) - - 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, extractor) +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 } - for _, domain := range response.Domains { - result := UpdateRecord(domain, response.Address, response.AddrType) - - if result != "" { - response.Success = false - response.Message = result + success := updateDomains(r, &response, func() { + json.NewEncoder(w).Encode(response) + }) - json.NewEncoder(w).Encode(response) - 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) - 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 updateDomains(r *http.Request, response *WebserviceResponse, onError func()) bool { + extractor := r.Context().Value(extractorKey).(requestDataExtractor) + + for _, record := range response.Records { + for _, domain := range response.Domains { + recordUpdate := RecordUpdateRequest{ + domain: domain, + ipAddr: record.Value, + addrType: record.Type, + secret: extractor.Secret(r), + ddnsKeyName: extractor.DdnsKeyName(r, domain), + zone: extractor.Zone(r, domain), + fqdn: extractor.Fqdn(r, domain), + action: extractor.Action(r), + } + result, err := recordUpdate.updateRecord() + + if err != nil { + response.Success = false + response.Message = err.Error() - f, err := ioutil.TempFile(os.TempDir(), "dyndns") - if err != nil { - return err.Error() + onError() + return false + } + response.Success = true + if len(response.Message) != 0 { + response.Message += "; " + } + response.Message += 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() + return true +} + +func (r RecordUpdateRequest) updateRecord() (string, error) { + var nsupdate NSUpdateInterface = NewNSUpdate() + 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() + + log.Println(message) - return out.String() + if result != "" { + return "", fmt.Errorf("%s", result) + } + return message, nil } diff --git a/rest-api/nsupdate.go b/rest-api/nsupdate.go new file mode 100644 index 0000000..16d3e93 --- /dev/null +++ b/rest-api/nsupdate.go @@ -0,0 +1,154 @@ +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) + DeleteRecord(r RecordUpdateRequest) + Close() string +} + +type UpdateRequestAction int + +const ( + UpdateRequestActionUpdate UpdateRequestAction = iota + UpdateRequestActionDelete +) + +// RecordUpdateRequest data representing a update request +type RecordUpdateRequest struct { + domain string + ipAddr string + addrType string + ddnsKeyName string + secret string + zone string + fqdn string + action UpdateRequestAction +} + +// 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 + authSent bool + needSend bool +} + +// 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) + nsupdate.authSent = false + nsupdate.needSend = false + + 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 + + if nsupdate.needSend { + nsupdate.Send() + } + + 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) { + 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+".") + } + if r.ddnsKeyName != "" { + nsupdate.write("key hmac-sha256:ddns-key.%s %s\n", escape(r.ddnsKeyName), escape(r.secret)) + } + 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 new file mode 100644 index 0000000..092e125 --- /dev/null +++ b/rest-api/request_data_extractor.go @@ -0,0 +1,101 @@ +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 + 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 + Action(r *http.Request) UpdateRequestAction +} + +type defaultRequestDataExtractor struct { + appConfig *Config +} + +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) 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) 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 != "" { + 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 } + +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_data_extractor_test.go b/rest-api/request_data_extractor_test.go new file mode 100644 index 0000000..3f91550 --- /dev/null +++ b/rest-api/request_data_extractor_test.go @@ -0,0 +1,129 @@ +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), + action: extractor.Action(r), + } + 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) + } + if rru.action != expected.action { + t.Fatalf("Wrong action: %v != %v", rru.action, expected.action) + } +} + +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", + }) +} + +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 2199454..b6dd488 100644 --- a/rest-api/request_handler.go +++ b/rest-api/request_handler.go @@ -9,15 +9,9 @@ import ( "net/http" "strings" - "dyndns/ipparser" + "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 @@ -25,69 +19,90 @@ type WebserviceResponse struct { Domains []string Address string AddrType string + Records []Record +} + +type Record struct { + Value string + Type string } -func BuildWebserviceResponseFromRequest(r *http.Request, appConfig *Config, extractors RequestDataExtractor) WebserviceResponse { +func ParseAddress(address string) (Record, error) { + if ipparser.ValidIP4(address) { + return Record{Value: address, Type: "A"}, nil + } else if ipparser.ValidIP6(address) { + return Record{Value: address, Type: "AAAA"}, nil + } + return Record{}, fmt.Errorf("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), ",") { + if address == "" { + continue + } + var parsedAddress, error = ParseAddress(address) + if error == nil { + 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 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 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 } } - // 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 + req := Record{extractors.Value(r), extractors.Type(r)} + if req.Type != "" && req.Value != "" { + response.Records = append(response.Records, req) + } - ip, err = getUserIP(r) + if len(response.Records) == 0 { + 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 + if err == nil { + parsedAddress, err := ParseAddress(ip) + if err == nil { + response.Records = append(response.Records, parsedAddress) + } } + } - // @todo refactor this code to remove duplication - if ipparser.ValidIP4(ip) { - response.AddrType = "A" - } else if ipparser.ValidIP6(ip) { - response.AddrType = "AAAA" - } else { + if extractors.Action(r) == UpdateRequestActionUpdate { + if len(response.Records) == 0 { 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)) + response.Message = "No valid update data could be extracted from request" + log.Println(response.Message) return response } - response.Address = ip + // 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 @@ -128,27 +143,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"), }, @@ -168,4 +183,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 ba6681a..570ee16 100644 --- a/rest-api/request_handler_test.go +++ b/rest-api/request_handler_test.go @@ -1,32 +1,16 @@ package main import ( + "encoding/base64" "net/http" "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{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update?secret=changeme&domain=foo&addr=1.2.3.4", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -39,18 +23,17 @@ func TestBuildWebserviceResponseFromRequestToReturnValidObject(t *testing.T) { t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.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.AddrType != "A" { + if result.Records[0].Type != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } 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") @@ -64,18 +47,17 @@ func TestBuildWebserviceResponseFromRequestWithXRealIPHeaderToReturnValidObject( t.Fatalf("Expected WebserviceResponse.Domain to be foo") } - if result.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.AddrType != "A" { + if result.Records[0].Type != "A" { t.Fatalf("Expected WebserviceResponse.AddrType to be A") } } 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") @@ -86,21 +68,20 @@ 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.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.AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A") + if result.Records[0].Type != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Records[0].Type) } } func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGiven(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/update", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, defaultExtractor) @@ -112,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) @@ -124,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) @@ -136,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) @@ -160,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) @@ -172,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) @@ -184,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) @@ -196,10 +171,9 @@ 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 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) @@ -208,21 +182,20 @@ 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.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.AddrType != "A" { - t.Fatalf("Expected WebserviceResponse.AddrType to be A") + if result.Records[0].Type != "A" { + t.Fatalf("Expected WebserviceResponse.AddrType to be A but was %s", result.Records[0].Type) } } func TestBuildWebserviceResponseFromRequestToReturnInvalidObjectWhenNoSecretIsGivenWithDynExtractor(t *testing.T) { var appConfig = &Config{} - appConfig.SharedSecret = "changeme" req, _ := http.NewRequest("GET", "/nic/update", nil) result := BuildWebserviceResponseFromRequest(req, appConfig, dynExtractor) @@ -231,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") + } +} 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 <