From ef22e29ceff701b8831cc79f812fab3999a11f04 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Tue, 13 Jul 2021 13:36:37 +0200 Subject: [PATCH] rewrite most code; removes database to solely rely on config file --- auth.go | 37 ------------- backend.go | 68 ------------------------ config.example.toml | 13 +++-- config.go | 48 +++++++++++++++++ data.go | 72 ------------------------- go.mod | 8 +++ go.sum | 12 +++++ main.go | 20 ++----- nameserver.go | 91 ++++++++++++++------------------ web.go | 126 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 246 insertions(+), 249 deletions(-) delete mode 100644 auth.go delete mode 100644 backend.go create mode 100644 config.go delete mode 100644 data.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 web.go diff --git a/auth.go b/auth.go deleted file mode 100644 index 09886d7..0000000 --- a/auth.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "golang.org/x/crypto/bcrypt" - "database/sql" - "fmt" - "net/http" -) - -func authenticateUser(db *sql.DB, username, password string) bool { - hashedPassword, ok := getPasswordForUser(db, username) - if ok { - err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)) - //TODO: print error message? - return err == nil - } else { - return false - } -} - -func basicAuth(db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := r.BasicAuth() - if !ok || !authenticateUser(db, user, pass) { - w.Header().Set("WWW-Authenticate", `Basic realm="dyndns"`) - w.WriteHeader(401) - w.Write([]byte("badauth")) - return - } - - userdata, err := getDataForUser(db, user) - if err != nil { - fmt.Println(err) - } - handleRequest(w, r, userdata) - } -} diff --git a/backend.go b/backend.go deleted file mode 100644 index 6c52172..0000000 --- a/backend.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "net" - "net/http" - "strings" -) - -// check if net.ParseIP returns nil!! -func getIP(r *http.Request) net.IP { - addr := r.URL.Query().Get("myip") - if len(addr) > 0 { - return net.ParseIP(addr) - } - - addr = r.Header.Get(http.CanonicalHeaderKey("X-Forwarded-For")) - if len(addr) > 0 { - end := strings.Index(addr, ", ") - if end == -1 { - end = len(addr) - } - return net.ParseIP(addr[:end]) - } - - addr = r.Header.Get(http.CanonicalHeaderKey("X-Real-IP")) - if len(addr) > 0 { - return net.ParseIP(addr) - } - - addr, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - panic(err) - } - return net.ParseIP(addr) -} - -func handleRequest(w http.ResponseWriter, r *http.Request, data userData) { - msg := "good" - defer func() { - w.Write([]byte(msg)) - }() - - hostname := r.URL.Query().Get("hostname") - if len(hostname) <= 0 || hostname != data.hostname { - msg = "nohost" - return - } - - ipaddr := getIP(r) - if ipaddr == nil { - //TODO: - msg = "error" - return - } - msg = "Hostname: " + hostname + " , IP: " + ipaddr.String() - - updated, err := updateNameserver(data, ipaddr) - if err != nil { - msg = "dnserr" - fmt.Println(err) - return - } - if !updated { - msg = "nochg" - return - } -} diff --git a/config.example.toml b/config.example.toml index 1748a08..e2d5d24 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,9 @@ -[database] -host = "127.0.0.1" port = 5432 -user = "username" -pass = "password" -name = "database" + +[[rrconfig]] +username = "username" +password = "password" +nameserver = "ns1.example.org" +zonename = "example.org" +hostname = "somerecord.example.org" +tsigkey = "" diff --git a/config.go b/config.go new file mode 100644 index 0000000..e845ba7 --- /dev/null +++ b/config.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "github.com/pelletier/go-toml" + "os" +) + +type Config struct { + ServerPort uint16 `toml:"port"` + RRConfigs []RRConfig `toml:"rrconfig"` + + rrconfigs map[string]*RRConfig +} + +type RRConfig struct { + Username string `toml:"username"` + Password string `toml:"password"` + Nameserver string `toml:"nameserver"` + Zonename string `toml:"zonename"` + Hostname string `toml:"hostname"` + Tsigkey string `toml:"tsigkey"` + Ttl int `toml:"ttl" default:"60"` +} + +func LoadConfig(path string) (*Config, error) { + var cfg Config + + f, err := os.Open(path) + if err != nil { + return nil, err + } + + decoder := toml.NewDecoder(f).Strict(true) + err = decoder.Decode(&cfg) + if err != nil { + return nil, err + } + + cfg.rrconfigs = map[string]*RRConfig{} + for _, entry := range cfg.RRConfigs { + if _, ok := cfg.rrconfigs[entry.Username]; ok { + return nil, fmt.Errorf("Duplicate username detected") + } + cfg.rrconfigs[entry.Username] = &entry + } + return &cfg, nil +} diff --git a/data.go b/data.go deleted file mode 100644 index bb99c7e..0000000 --- a/data.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "fmt" - "database/sql" - _ "github.com/lib/pq" -) - -type databaseConfig struct { - Host string - Port int - User string - Pass string - Name string -} - -type userData struct { - nameserver string - zonename string - hostname string - tsigkey string -} - -func prepareDatabase() (*sql.DB, error) { - dbconf := config.Database - psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s " + - "sslmode=disable", dbconf.Host, dbconf.Port, - dbconf.User, dbconf.Pass, dbconf.Name) - db, err := sql.Open("postgres", psqlInfo) - if err != nil { - return nil, err - } - - _, err = db.Exec("CREATE TABLE IF NOT EXISTS users " + - "(id SERIAL PRIMARY KEY, username VARCHAR(255) NULL, " + - "password VARCHAR(255) NULL, salt VARCHAR(255) NULL, " + - "nameserver VARCHAR(255) NULL, zone VARCHAR(255) NULL, " + - "hostname VARCHAR(255) NULL, tsig VARCHAR(255) NULL)") - if err != nil { - db.Close() - return nil, err - } - return db, nil -} - -func getPasswordForUser(db *sql.DB, username string) ([]byte, bool) { - var password []byte - - row := db.QueryRow("SELECT password FROM users WHERE username=$1", username) - err := row.Scan(&password) - if err != nil { - if err == sql.ErrNoRows { - return nil, false - } else { - panic(err) - } - } - return password, true -} - -func getDataForUser(db *sql.DB, username string) (userData, error) { - var data userData - - row := db.QueryRow("SELECT nameserver, zone, hostname, tsig " + - "FROM users WHERE username=$1", username) - err := row.Scan(&data.nameserver, &data.zonename, &data.hostname, - &data.tsigkey) - if err != nil { - return data, err - } - return data, nil -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28ec72e --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.preisner.eu/preisi/dyndns + +go 1.16 + +require ( + github.com/pelletier/go-toml v1.9.3 + golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13aad2f --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 4093d9d..7a50b82 100644 --- a/main.go +++ b/main.go @@ -2,34 +2,22 @@ package main import ( "flag" + "fmt" "net/http" - "github.com/BurntSushi/toml" ) -type tomlConfig struct { - Database databaseConfig -} - -var config tomlConfig - func main() { var configPath string flag.StringVar(&configPath, "config", "config.toml", "path to config file") flag.Parse() - _, err := toml.DecodeFile(configPath, &config) + cfg, err := LoadConfig(configPath) if err != nil { panic(err) } - db, err := prepareDatabase() - if err != nil { - panic(err) - } - defer db.Close() - - http.HandleFunc("/", basicAuth(db)) - err = http.ListenAndServe(":3002", nil) + http.HandleFunc("/", RequestHandler(cfg)) + err = http.ListenAndServe(fmt.Sprintf(":%d", cfg.ServerPort), nil) if err != nil { panic(err) } diff --git a/nameserver.go b/nameserver.go index c68e45d..1090331 100644 --- a/nameserver.go +++ b/nameserver.go @@ -1,70 +1,59 @@ package main import ( - "bytes" "fmt" + "log" "net" "os/exec" "strings" ) -func createQuery(data userData, addr net.IP, deleteRecord bool) string { - var query strings.Builder - - query.WriteString(fmt.Sprintf("key %s\n", data.tsigkey)) - query.WriteString(fmt.Sprintf("server %s\n", data.nameserver)) - query.WriteString(fmt.Sprintf("zone %s\n", data.zonename)) - if deleteRecord { - query.WriteString(fmt.Sprintf("update delete %s. A\n", data.hostname)) +func requiresRRUpdate(entry *RRConfig, addr net.IP) bool { + // TODO: use custom resolver to query authoritive nameserver instead of + // local resolver + addrs, err := net.LookupIP(entry.Hostname) + if err != nil { + log.Printf("dns lookup failed: %s", err) + // enforce update, it's better than not trying at all + return true } - query.WriteString(fmt.Sprintf("update add %s. A %s\n", data.hostname, - addr.String())) - query.WriteString("show\n") - query.WriteString("send\n") - return query.String() + // check if the current ip matches + for _, ip := range addrs { + if ip.Equal(addr) { + // the ip seems to be still up-to-date -> no update required + return false + } + } + return true } -func queryNameserver(query string) error { - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) +func updateRR(entry *RRConfig, addr net.IP) error { + query := generateQuery(entry, addr) + return executeQuery(query) +} +func generateQuery(entry *RRConfig, addr net.IP) string { + var q strings.Builder + + fmt.Fprintf(&q, "key %s\n", entry.Tsigkey) + fmt.Fprintf(&q, "server %s\n", entry.Nameserver) + fmt.Fprintf(&q, "zone %s\n", entry.Zonename) + // TODO: check if addr is ipv4 or ipv6 (-> update A or AAAA) + fmt.Fprintf(&q, "add %s. %d A %s\n", entry.Hostname, entry.Ttl, addr.String()) + fmt.Fprintf(&q, "send\n") + + return q.String() +} + +func executeQuery(query string) error { cmd := exec.Command("knsupdate", "-v") cmd.Stdin = strings.NewReader(query) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - fmt.Printf(stdout.String()) - fmt.Printf(stderr.String()) + output, err := cmd.CombinedOutput() + // TODO: is outputting stdout+stderr on failure even necessary? + if err != nil { + log.Printf("executeQuery failed: %s", output) + } return err } - -func updateNameserver(data userData, addr net.IP) (bool, error) { - updateRecord := true - deleteRecord := true - - addrs, err := net.LookupIP(data.hostname) - if err != nil { - deleteRecord = false - } else { - for _, ip := range addrs { - if ip.Equal(addr) { - updateRecord = false - break - } - } - } - if !updateRecord { - return false, nil - } else { - query := createQuery(data, addr, deleteRecord) - err = queryNameserver(query) - if err != nil { - return true, err - } - return true, nil - } -} diff --git a/web.go b/web.go new file mode 100644 index 0000000..6a31952 --- /dev/null +++ b/web.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "golang.org/x/crypto/bcrypt" + "log" + "net" + "net/http" + "strings" +) + +func isAuthenticated(cfg *Config, r *http.Request) *RRConfig { + user, pw, ok := r.BasicAuth() + if !ok { + // no basic auth header detected + return nil + } + + entry, ok := cfg.rrconfigs[user] + if !ok { + // non-existent username + return nil + } + + err := bcrypt.CompareHashAndPassword([]byte(entry.Password), []byte(pw)) + if err != nil { + log.Printf("Config contains invalid password hash for user %q", user) + return nil + } + return entry +} + +func getIpAddress(r *http.Request) net.IP { + addr := r.URL.Query().Get("myip") + if len(addr) > 0 { + // If myip was supplied, parse it and return the result regardless of + // success, failure will be handled later. + return net.ParseIP(addr) + } + + // check http headers and request for other possible ip address sources + addr = r.Header.Get(http.CanonicalHeaderKey("X-Forwarded-For")) + if len(addr) > 0 { + tokens := strings.Split(addr, ", ") + // tokens is always at least 1 element long + // -> return first element as it contains the client's ip address + return net.ParseIP(tokens[0]) + } + // TODO: support newer standarized "Forwarded"-Header + + addr = r.Header.Get(http.CanonicalHeaderKey("X-Real-IP")) + if len(addr) > 0 { + return net.ParseIP(addr) + } + + addr, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.Printf("Retrieving IP-Address failed: %s", err) + return nil + } + return net.ParseIP(addr) +} + +// returns api-response on failure +func verifyHostname(entry *RRConfig, hostname string) string { + if len(hostname) <= 0 { + return "nohost" + } + + // TODO: allow single user to update multiple hostnames + // TODO: return ntfqdn -> differentiate between 'hostname doesnt exist' and + // 'hostname is not fqdn' + if hostname != entry.Hostname { + return "nohost" + } + return "" +} + +func RequestHandler(cfg *Config) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + userdata := isAuthenticated(cfg, r) + if userdata == nil { + w.Header().Set("WWW-Authenticate", `Basic realm="dyndns"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("badauth")) + fmt.Fprintln(w, "badauth") + return + } + + // all remaining responses should come with status code 200 + w.WriteHeader(http.StatusOK) + + // check for 'valid' useragent, should not be strictly necessary but + // the protocol demands it + if len(r.UserAgent()) <= 0 { + fmt.Fprintln(w, "badagent") + return + } + + hostname := r.URL.Query().Get("hostname") + response := verifyHostname(userdata, hostname) + if response != "" { + fmt.Fprintln(w, response) + return + } + + ipaddr := getIpAddress(r) + if ipaddr == nil { + fmt.Fprintln(w, "911") + return + } + + if !requiresRRUpdate(userdata, ipaddr) { + fmt.Fprintf(w, "nochg %s\n", ipaddr.String()) + return + } + + err := updateRR(userdata, ipaddr) + if err != nil { + log.Printf("updating RR failed: %s", err) + fmt.Fprintln(w, "911") + return + } + fmt.Fprintf(w, "good %s\n", ipaddr.String()) + } +}