diff --git a/.dockerignore b/.dockerignore index 305ecaf54..375cfe476 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,9 @@ -.gitignore -.travis.yml -.git/ +.git *.exe -README.md +.vscode +.travis.yml docker-compose.yml -readme/ \ No newline at end of file +LICENSE +*.md +readme +.gitignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c3175318..d4b20d90d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,2 @@ -# Binaries for programs and plugins *.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, build with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - .vscode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c28343fda..0cb8a2557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,17 @@ ARG ALPINE_VERSION=3.9 ARG GO_VERSION=1.12.4 +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder +RUN apk --update add git build-base upx +RUN go get -u -v golang.org/x/vgo +WORKDIR /tmp/gobuild +COPY go.mod go.sum ./ +RUN go mod download +COPY pkg/ ./pkg/ +COPY main.go . +#RUN go test -v +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-s -w" -o app . + FROM alpine:${ALPINE_VERSION} AS final ARG BUILD_DATE ARG VCS_REF @@ -16,7 +27,7 @@ LABEL org.label-schema.schema-version="1.0.0-rc1" \ org.label-schema.docker.cmd.devel="docker run -it --rm -p 8000:8000/tcp -e RECORD1=example.com,@,namecheap,provider,0e4512a9c45a4fe88313bcc2234bf547 qmcgaw/ddns-updater" \ org.label-schema.docker.params="See readme" \ org.label-schema.version="" \ - image-size="19.3MB" \ + image-size="21.4MB" \ ram-usage="13MB" \ cpu-usage="Very Low" RUN apk add --update sqlite ca-certificates && \ @@ -32,17 +43,11 @@ EXPOSE 8000 HEALTHCHECK --interval=300s --timeout=5s --start-period=5s --retries=1 CMD ["/updater/app", "healthcheck"] USER 1000 ENTRYPOINT ["/updater/app"] - -FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder -RUN apk --update add git build-base upx -RUN go get -u -v golang.org/x/vgo -WORKDIR /tmp/gobuild -COPY updater/go.mod updater/go.sum ./ -RUN go mod download -COPY updater/*.go ./ -#RUN go test -v -RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-s -w" -o app . - -FROM final +ENV DELAY= \ + ROOTURL= \ + LISTENINGPORT= \ + RECORD0= \ + LOGGING= \ + NODEID= COPY --from=builder --chown=1000 /tmp/gobuild/app /updater/app -COPY --chown=1000 updater/ui/* /updater/ui/ +COPY --chown=1000 ui/* /updater/ui/ diff --git a/README.md b/README.md index 1a314d1c2..833575736 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | Image size | RAM usage | CPU usage | | --- | --- | --- | -| 19.3MB | 13MB | Very low | +| 21.4MB | 13MB | Very low | ## Features @@ -63,7 +63,9 @@ chmod 700 data/ | `DELAY` | `300` | Delay between updates in seconds | | `ROOTURL` | `/` | URL path to append to all paths (i.e. `/ddns` for accessing `https://example.com/ddns`) | | `LISTENINGPORT` | `8000` | Internal TCP listening port for the web UI | -| `RECORDi` | | A record to update in the form `domain_name,host,provider,ip_method,password` | +| `RECORDi` | | A record `i` to update in the form `domain_name,host,provider,ip_method,password` | +| `LOGGING` | `json` | Format of logging, `json` or `human` | +| `NODEID` | `0` | Node ID (for distributed systems), can be any integer | - The environement variables `RECORD1`, `RECORD2`, etc. are domains to update the IP address for - The program reads them, starting at `RECORD1` and will stop as soon as `RECORDn` is not set @@ -158,6 +160,8 @@ In this example, the key is `dLP4WKz5PdkS_GuUDNigHcLQFpw4CWNwAQ5` and the secret ## TODOs +- [ ] ARM travis builds +- [ ] Break update function (pkg/update/update.go) - [ ] Read parameters from JSON file - [ ] Unit tests - [ ] Finish readme diff --git a/docker-compose.yml b/docker-compose.yml index 70db649f6..7dab3ef62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - DELAY=300 - ROOTURL= - LISTENINGPORT=8000 + - LOGGING=human + - NODEID=0 - RECORD1=example.com,@,namecheap,provider,0e4512a9c45a4fe88313bcc2234bf547 - RECORD2=example.info,@,namecheap,duckduckgo,157fd2a9c45a4fe88313bcc2234bfd58 - RECORD3=example.io,www,namecheap,opendns,0e4512a9c45a4fe88313bcc2234bf547 diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..7b2e2670f --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module ddns-updater + +go 1.12 + +require ( + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/fatih/color v1.7.0 + github.com/google/uuid v1.1.1 + github.com/julienschmidt/httprouter v1.2.0 + github.com/kyokomi/emoji v2.1.0+incompatible + github.com/mattn/go-colorable v0.1.1 // indirect + github.com/mattn/go-isatty v0.0.7 // indirect + github.com/mattn/go-sqlite3 v1.10.0 + github.com/spf13/viper v1.3.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..5addfa00e --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o= +github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 000000000..44b55e1f6 --- /dev/null +++ b/main.go @@ -0,0 +1,94 @@ +package main + +import ( + _ "github.com/mattn/go-sqlite3" + + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "ddns-updater/pkg/database" + + "ddns-updater/pkg/healthcheck" + "ddns-updater/pkg/logging" + "ddns-updater/pkg/params" + "ddns-updater/pkg/update" + "ddns-updater/pkg/network" + "ddns-updater/pkg/server" + + "github.com/kyokomi/emoji" +) + +func main() { + if healthcheck.Mode() { + healthcheck.Query() + } + fmt.Println("#################################") + fmt.Println("##### DDNS Universal Updater ####") + fmt.Println("######## by Quentin McGaw #######") + fmt.Println("######## Give some " + emoji.Sprint(":heart:") + "at #########") + fmt.Println("# github.com/qdm12/ddns-updater #") + fmt.Print("#################################\n\n") + go waitForExit() + logging.SetGlobalLoggerLevel(logging.InfoLevel) + loggerMode := params.GetLoggerMode() + logging.SetGlobalLoggerMode(loggerMode) + nodeID := params.GetNodeID() + logging.SetGlobalLoggerNodeID(nodeID) + httpClient := &http.Client{Timeout: 10 * time.Second} + dir := params.GetDir() + listeningPort := params.GetListeningPort() + rootURL := params.GetRootURL() + delay := params.GetDelay() + recordsConfigs := params.GetRecordConfigs() + logging.Info("Found %d records to update", len(recordsConfigs)) + go healthcheck.Serve(recordsConfigs) + dataDir := params.GetDataDir(dir) + errs := network.ConnectivityChecks(httpClient, []string{"google.com"}) + for _, err := range errs { + logging.Warn("%s", err) + } + sqlDb, err := database.NewDb(dataDir) + if err != nil { + logging.Fatal("%s", err) + } + for i := range recordsConfigs { + domain := recordsConfigs[i].Settings.Domain + host := recordsConfigs[i].Settings.Host + logging.Info("Reading history for domain %s and host %s", domain, host) + ips, tSuccess, err := sqlDb.GetIps(domain, host) + if err != nil { + logging.Fatal("%s", err) + } + recordsConfigs[i].Lock() + recordsConfigs[i].History.IPs = ips + recordsConfigs[i].History.TSuccess = tSuccess + recordsConfigs[i].Unlock() + } + forceCh := make(chan struct{}) + quitCh := make(chan struct{}) + go update.TriggerServer(delay, forceCh, quitCh, recordsConfigs, httpClient, sqlDb) + forceCh <- struct{}{} + router := server.CreateRouter(rootURL, dir, forceCh, recordsConfigs) + logging.Info("Web UI listening on 0.0.0.0:%s", listeningPort) + err = http.ListenAndServe("0.0.0.0:"+listeningPort, router) + if err != nil { + logging.Fatal("%s", err) + } +} + +func waitForExit() { + signals := make(chan os.Signal) + signal.Notify(signals, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGKILL, + os.Interrupt, + ) + signal := <-signals + logging.Warn("Caught OS signal: %s", signal) + os.Exit(0) +} diff --git a/pkg/database/init.go b/pkg/database/init.go new file mode 100644 index 000000000..7787465de --- /dev/null +++ b/pkg/database/init.go @@ -0,0 +1,36 @@ +package database + +import ( + "database/sql" + "strings" +) + +// A sqlite database is used to store previous IPs, when re launching the program. + +// DB contains the database connection pool pointer. +// It is used so that methods are declared on it, in order +// to mock the database easily, through the help of the Datastore interface +// WARNING: Use in one single go routine, it is not thread safe ! +type DB struct { + *sql.DB +} + +// NewDb opens or creates the database if necessary. +func NewDb(dataDir string) (*DB, error) { + dataDir = strings.TrimSuffix(dataDir, "/") + db, err := sql.Open("sqlite3", dataDir+"/updates.db") + if err != nil { + return nil, err + } + _, err = db.Exec( + `CREATE TABLE IF NOT EXISTS updates_ips ( + domain TEXT NOT NULL, + host TEXT NOT NULL, + ip TEXT NOT NULL, + t_new DATETIME NOT NULL, + t_last DATETIME NOT NULL, + current INTEGER DEFAULT 1 NOT NULL, + PRIMARY KEY(domain, host, ip, t_new) + );`) + return &DB{db}, err +} diff --git a/pkg/database/queries.go b/pkg/database/queries.go new file mode 100644 index 000000000..1e4c5f84d --- /dev/null +++ b/pkg/database/queries.go @@ -0,0 +1,80 @@ +package database + +import ( + "time" +) + +/* All these methods must be called by a single go routine as they are not +thread safe because of SQLite */ + +func (db *DB) UpdateIPTime(domain, host, ip string) (err error) { + _, err = db.Exec( + `UPDATE updates_ips + SET t_last = ? + WHERE domain = ? AND host = ? AND ip = ? AND current = 1`, + time.Now(), + domain, + host, + ip, + ) + return err +} + +func (db *DB) StoreNewIP(domain, host, ip string) (err error) { + // Disable the current IP + _, err = db.Exec( + `UPDATE updates_ips + SET current = 0 + WHERE domain = ? AND host = ? AND current = 1`, + domain, + host, + ) + if err != nil { + return err + } + // Inserts new IP + _, err = db.Exec( + `INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current) + VALUES(?, ?, ?, ?, ?, ?);`, + domain, + host, + ip, + time.Now(), + time.Now(), + 1, + ) + return err +} + +func (db *DB) GetIps(domain, host string) (ips []string, tNew time.Time, err error) { + rows, err := db.Query( + `SELECT ip, t_new + FROM updates_ips + WHERE domain = ? AND host = ? + ORDER BY t_new DESC`, + domain, + host, + ) + if err != nil { + return nil, tNew, err + } + defer rows.Close() + var ip string + var t time.Time + var tNewSet bool + for rows.Next() { + err = rows.Scan(&ip, &t) + if err != nil { + return ips, tNew, err + } + if !tNewSet { + tNew = t + tNewSet = true + } + ips = append(ips, ip) + } + if !tNewSet { + tNew = time.Now() + } + return ips, tNew, rows.Err() +} diff --git a/pkg/healthcheck/query.go b/pkg/healthcheck/query.go new file mode 100644 index 000000000..e4bf07549 --- /dev/null +++ b/pkg/healthcheck/query.go @@ -0,0 +1,44 @@ +package healthcheck + +import ( + "fmt" + "net/http" + "os" + "time" +) + +// Mode checks if the program is +// launched to run the Docker internal healthcheck. +func Mode() bool { + args := os.Args + if len(args) > 1 && args[1] == "healthcheck" { + if len(args) > 2 { + fmt.Println("Too many arguments provided for command healthcheck") + os.Exit(1) + } + return true + } + return false +} + +// Query sends an HTTP request to +// the other instance of the program's healthcheck +// server route. +func Query() { + request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:9999", nil) + if err != nil { + fmt.Print("Cannot build HTTP request") + os.Exit(1) + } + client := &http.Client{Timeout: time.Duration(1000) * time.Millisecond} + response, err := client.Do(request) + if err != nil { + fmt.Print("Cannot execute HTTP request") + os.Exit(1) + } + if response.StatusCode != 200 { + fmt.Print("Status code is " + response.Status) + os.Exit(1) + } + os.Exit(0) +} diff --git a/pkg/healthcheck/server.go b/pkg/healthcheck/server.go new file mode 100644 index 000000000..b649d48ab --- /dev/null +++ b/pkg/healthcheck/server.go @@ -0,0 +1,68 @@ +package healthcheck + +import ( + "net" + "net/http" + "fmt" + + "github.com/julienschmidt/httprouter" + + "ddns-updater/pkg/logging" + "ddns-updater/pkg/models" +) + +type paramsType struct { + recordsConfigs []models.RecordConfigType +} + +// Serve healthcheck HTTP requests and listens on +// localhost:9999 only. +func Serve(recordsConfigs []models.RecordConfigType) { + params := paramsType{ + recordsConfigs: recordsConfigs, + } + localRouter := httprouter.New() + localRouter.GET("/", params.get) + logging.Info("Private server listening on 127.0.0.1:9999") + err := http.ListenAndServe("127.0.0.1:9999", localRouter) + if err != nil { + logging.Fatal("%s", err) + } +} + +func (params *paramsType) get(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + err := getHandler(params.recordsConfigs) + if err != nil { + logging.Warn("Responded with error to healthcheck: %s", err) + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } +} + +func getHandler(recordsConfigs []models.RecordConfigType) error { + for i := range recordsConfigs { + if recordsConfigs[i].Status.Code == models.FAIL { + return fmt.Errorf("%s", recordsConfigs[i].String()) + } + if recordsConfigs[i].Status.Code != models.UPDATING { + ips, err := net.LookupIP(recordsConfigs[i].Settings.BuildDomainName()) + if err != nil { + return fmt.Errorf("%s", err) + } + if len(recordsConfigs[i].History.IPs) == 0 { + return fmt.Errorf("no set IP address found") + } + for i := range ips { + if ips[i].String() != recordsConfigs[i].History.IPs[0] { + return fmt.Errorf( + "lookup IP address of %s is not %s", + recordsConfigs[i].Settings.BuildDomainName(), + recordsConfigs[i].History.IPs[0], + ) + } + } + } + } + return nil +} diff --git a/pkg/logging/glogger.go b/pkg/logging/glogger.go new file mode 100644 index 000000000..f5feb6000 --- /dev/null +++ b/pkg/logging/glogger.go @@ -0,0 +1,91 @@ +package logging + +var gLogger *Logger + +func init() { + gLogger = &Logger{ + mode: Default, + json: createJSONLogger(InfoLevel, 0), + human: createHumanLogger(InfoLevel, 0), + } +} + +// SetGlobalLoggerMode sets the mode of the global logger +func SetGlobalLoggerMode(mode Mode) { + gLogger.m.Lock() + gLogger.mode = mode + gLogger.m.Unlock() +} + +// SetGlobalLoggerLevel sets the level of the global logger +func SetGlobalLoggerLevel(level Level) { + gLogger.m.Lock() + nodeID := gLogger.json.nodeID + gLogger.json = createJSONLogger(level, nodeID) + gLogger.human = createHumanLogger(level, nodeID) + gLogger.m.Unlock() +} + +// SetGlobalLoggerNodeID sets the node ID of the global logger +func SetGlobalLoggerNodeID(nodeID int) { + gLogger.m.Lock() + level := gLogger.json.level + gLogger.json = createJSONLogger(level, nodeID) + gLogger.human = createHumanLogger(level, nodeID) + gLogger.m.Unlock() +} + +// Fatal logs a message and exit the program +func Fatal(message string, fargs ...interface{}) { + gLogger.m.RLock() + defer gLogger.m.RUnlock() + if gLogger.mode == JSON || gLogger.mode == Default { + gLogger.json.fatal(message, fargs...) + } else if gLogger.mode == Human { + gLogger.human.fatal(message, fargs...) + } +} + +// Error logs an error message if the level of the logger is set to higher or equal to ErrorLevel +func Error(message string, fargs ...interface{}) { + gLogger.m.RLock() + defer gLogger.m.RUnlock() + if gLogger.mode == JSON || gLogger.mode == Default { + gLogger.json.error(message, fargs...) + } else if gLogger.mode == Human { + gLogger.human.error(message, fargs...) + } +} + +// Warn logs a warning message if the level of the logger is set to higher or equal to WarningLevel +func Warn(message string, fargs ...interface{}) { + gLogger.m.RLock() + defer gLogger.m.RUnlock() + if gLogger.mode == JSON || gLogger.mode == Default { + gLogger.json.warn(message, fargs...) + } else if gLogger.mode == Human { + gLogger.human.warn(message, fargs...) + } +} + +// Success logs a success message if the level of the logger is set to higher or equal to SuccessLevel +func Success(message string, fargs ...interface{}) { + gLogger.m.RLock() + defer gLogger.m.RUnlock() + if gLogger.mode == JSON || gLogger.mode == Default { + gLogger.json.success(message, fargs...) + } else if gLogger.mode == Human { + gLogger.human.success(message, fargs...) + } +} + +// Info logs a message if the level of the logger is set to higher or equal to InfoLevel +func Info(message string, fargs ...interface{}) { + gLogger.m.RLock() + defer gLogger.m.RUnlock() + if gLogger.mode == JSON || gLogger.mode == Default { + gLogger.json.info(message, fargs...) + } else if gLogger.mode == Human { + gLogger.human.info(message, fargs...) + } +} \ No newline at end of file diff --git a/pkg/logging/human.go b/pkg/logging/human.go new file mode 100644 index 000000000..356bea49b --- /dev/null +++ b/pkg/logging/human.go @@ -0,0 +1,53 @@ +package logging + +import ( + "fmt" + "log" + "os" +) + +type humanLogger struct { + level Level + nodeID int +} + +func createHumanLogger(level Level, nodeID int) humanLogger { + return humanLogger{ + level: level, + nodeID: nodeID, + } +} + +func makeLine(level Level, nodeID int, message string, fargs ...interface{}) string { + builtMessage := fmt.Sprintf("Node %d: %s", nodeID, fmt.Sprintf(message, fargs...)) + return level.formatHuman(builtMessage) +} + +func (logger *humanLogger) fatal(message string, fargs ...interface{}) { + log.Print(makeLine(logger.level, logger.nodeID, message, fargs...)) + os.Exit(1) +} + +func (logger *humanLogger) error(message string, fargs ...interface{}) { + if logger.level >= ErrorLevel { + log.Print(makeLine(logger.level, logger.nodeID, message, fargs...)) + } +} + +func (logger *humanLogger) warn(message string, fargs ...interface{}) { + if logger.level >= WarningLevel { + log.Print(makeLine(logger.level, logger.nodeID, message, fargs...)) + } +} + +func (logger *humanLogger) success(message string, fargs ...interface{}) { + if logger.level >= SuccessLevel { + log.Print(makeLine(logger.level, logger.nodeID, message, fargs...)) + } +} + +func (logger *humanLogger) info(message string, fargs ...interface{}) { + if logger.level >= InfoLevel { + log.Print(makeLine(logger.level, logger.nodeID, message, fargs...)) + } +} diff --git a/pkg/logging/json.go b/pkg/logging/json.go new file mode 100644 index 000000000..906377d4f --- /dev/null +++ b/pkg/logging/json.go @@ -0,0 +1,75 @@ +package logging + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type jsonLogger struct { + level Level + nodeID int +} + +func createJSONLogger(level Level, nodeID int) jsonLogger { + return jsonLogger{ + level: level, + nodeID: nodeID, + } +} + +func makeJSON(level Level, nodeID int, message string, fargs ...interface{}) string { + type jsonPayload struct { + Level string `json:"level"` + Message string `json:"message"` + Time time.Time `json:"time"` // generated on the fly + NodeID int `json:"node"` // constant on the instance + } + payload := jsonPayload{ + Level: level.string(), + Message: fmt.Sprintf(message, fargs...), + Time: time.Now(), + NodeID: nodeID, + } + b, err := json.Marshal(payload) + if err != nil { + b, _ := json.Marshal(&jsonPayload{ + Level: ErrorLevel.string(), + Message: fmt.Sprintf("cannot make JSON (%s) for payload: %v", err, payload), + Time: time.Now(), + NodeID: nodeID, + }) + return string(b) + } + return string(b) +} + +func (logger *jsonLogger) fatal(message string, fargs ...interface{}) { + fmt.Println(makeJSON(FatalLevel, logger.nodeID, message, fargs...)) + os.Exit(1) +} + +func (logger *jsonLogger) error(message string, fargs ...interface{}) { + if logger.level >= ErrorLevel { + fmt.Println(makeJSON(ErrorLevel, logger.nodeID, message, fargs...)) + } +} + +func (logger *jsonLogger) warn(message string, fargs ...interface{}) { + if logger.level >= WarningLevel { + fmt.Println(makeJSON(WarningLevel, logger.nodeID, message, fargs...)) + } +} + +func (logger *jsonLogger) success(message string, fargs ...interface{}) { + if logger.level >= SuccessLevel { + fmt.Println(makeJSON(SuccessLevel, logger.nodeID, message, fargs...)) + } +} + +func (logger *jsonLogger) info(message string, fargs ...interface{}) { + if logger.level >= InfoLevel { + fmt.Println(makeJSON(InfoLevel, logger.nodeID, message, fargs...)) + } +} diff --git a/pkg/logging/levels.go b/pkg/logging/levels.go new file mode 100644 index 000000000..17f3968ab --- /dev/null +++ b/pkg/logging/levels.go @@ -0,0 +1,54 @@ +package logging + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/kyokomi/emoji" +) + +// Level represents the level of the logger +type Level uint8 + +// Different logger levels available +const ( + FatalLevel Level = iota + ErrorLevel + WarningLevel + SuccessLevel + InfoLevel +) + +func (level Level) string() string { + switch level { + case FatalLevel: + return "Fatal" + case ErrorLevel: + return "Error" + case WarningLevel: + return "Warning" + case SuccessLevel: + return "Success" + case InfoLevel: + return "Info" + default: + return fmt.Sprintf("Unknown level %d", uint8(level)) + } +} + +func (level Level) formatHuman(message string) string { + switch level { + case FatalLevel: + return color.RedString(emoji.Sprintf(":x: %s: %s", level.string(), message)) + case ErrorLevel: + return color.HiRedString(emoji.Sprintf(":x: %s: %s", level.string(), message)) + case WarningLevel: + return color.HiYellowString(emoji.Sprintf(":warning: %s: %s", level.string(), message)) + case SuccessLevel: + return color.HiGreenString(emoji.Sprintf(":heavy_check_mark: %s: %s", level.string(), message)) + case InfoLevel: + return fmt.Sprintf("%s: %s", level.string(), message) + default: + return fmt.Sprintf("%s: %s", level.string(), message) + } +} diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go new file mode 100644 index 000000000..70cdf7da4 --- /dev/null +++ b/pkg/logging/logger.go @@ -0,0 +1,95 @@ +package logging + +import ( + "sync" +) + +// Logger is the structure for a logger instance and can contain multiple loggers +type Logger struct { + mode Mode + m sync.RWMutex + json jsonLogger + human humanLogger +} + +// Mode is the mode of the logger which can be Default, JSON or Human +type Mode uint8 + +// Different logger modes available +const ( + Default Mode = iota + JSON + Human +) + +// CreateLogger returns the pointer to a Logger which can act as human +// readable logger or a JSON formatted logger +func CreateLogger(mode Mode, level Level, nodeID int) *Logger { + return &Logger{ + mode: mode, + json: createJSONLogger(level, nodeID), + human: createHumanLogger(level, nodeID), + } +} + +// ChangeMode changes the logging mode of the logger +func (logger *Logger) ChangeMode(mode Mode) { + logger.m.Lock() + logger.mode = mode + logger.m.Unlock() +} + +// Fatal logs a message and exit the program +func (logger *Logger) Fatal(message string, fargs ...interface{}) { + logger.m.RLock() + defer logger.m.RUnlock() + if logger.mode == JSON || logger.mode == Default { + logger.json.fatal(message, fargs...) + } else if logger.mode == Human { + logger.human.fatal(message, fargs...) + } +} + +// Error logs an error message if the level of the logger is set to higher or equal to ErrorLevel +func (logger *Logger) Error(message string, fargs ...interface{}) { + logger.m.RLock() + defer logger.m.RUnlock() + if logger.mode == JSON || logger.mode == Default { + logger.json.error(message, fargs...) + } else if logger.mode == Human { + logger.human.error(message, fargs...) + } +} + +// Warn logs a warning message if the level of the logger is set to higher or equal to WarningLevel +func (logger *Logger) Warn(message string, fargs ...interface{}) { + logger.m.RLock() + defer logger.m.RUnlock() + if logger.mode == JSON || logger.mode == Default { + logger.json.warn(message, fargs...) + } else if logger.mode == Human { + logger.human.warn(message, fargs...) + } +} + +// Success logs a success message if the level of the logger is set to higher or equal to SuccessLevel +func (logger *Logger) Success(message string, fargs ...interface{}) { + logger.m.RLock() + defer logger.m.RUnlock() + if logger.mode == JSON || logger.mode == Default { + logger.json.success(message, fargs...) + } else if logger.mode == Human { + logger.human.success(message, fargs...) + } +} + +// Info logs a message if the level of the logger is set to higher or equal to InfoLevel +func (logger *Logger) Info(message string, fargs ...interface{}) { + logger.m.RLock() + defer logger.m.RUnlock() + if logger.mode == JSON || logger.mode == Default { + logger.json.info(message, fargs...) + } else if logger.mode == Human { + logger.human.info(message, fargs...) + } +} diff --git a/pkg/models/history.go b/pkg/models/history.go new file mode 100644 index 000000000..babb090d6 --- /dev/null +++ b/pkg/models/history.go @@ -0,0 +1,21 @@ +package models + +import "time" + +type historyType struct { + IPs []string // current and previous ips + TSuccess time.Time +} + +func (history *historyType) string() (s string) { + if len(history.IPs) > 0 { + s += "Last success update: " + history.TSuccess.Format("2006-01-02 15:04:05 MST") + "; Current & previous IPs: " + for i := range history.IPs { + s += history.IPs[i] + if i != len(history.IPs)-1 { + s += "," + } + } + } + return s +} diff --git a/pkg/models/html.go b/pkg/models/html.go new file mode 100644 index 000000000..604b45dd5 --- /dev/null +++ b/pkg/models/html.go @@ -0,0 +1,45 @@ +package models + +import ( + "fmt" + "time" +) + +// HTMLData is a list of HTML fields to be rendered. +// It is exported so that the HTML template engine can render it. +type HTMLData struct { + Rows []HTMLRow +} + +// HTMLRow contains HTML fields to be rendered +// It is exported so that the HTML template engine can render it. +type HTMLRow struct { + Domain string + Host string + Provider string + IPMethod string + Status string + IP string // current set ip + IPs []string // previous ips +} + +// ToHTML converts all the update record configs to HTML data ready to be templated +func ToHTML(recordsConfigs []RecordConfigType) (htmlData HTMLData) { + for i := range recordsConfigs { + htmlData.Rows = append(htmlData.Rows, recordsConfigs[i].toHTML()) + } + return htmlData +} + +func durationString(t time.Time) string { + duration := time.Since(t) + if duration < time.Minute { + return fmt.Sprintf("%ds", int(duration.Round(time.Second).Seconds())) + } else if duration < time.Hour { + return fmt.Sprintf("%dm", int(duration.Round(time.Minute).Minutes())) + } else if duration < 24*time.Hour { + return fmt.Sprintf("%dh", int(duration.Round(time.Hour).Hours())) + } else { + return fmt.Sprintf("%dd", int(duration.Round(time.Hour*24).Hours()/24)) + } +} diff --git a/pkg/models/recordconfig.go b/pkg/models/recordconfig.go new file mode 100644 index 000000000..f57d4f56e --- /dev/null +++ b/pkg/models/recordconfig.go @@ -0,0 +1,47 @@ +package models + +import ( + "sync" +) + +// RecordConfigType contains all the information to update and display a DNS record +type RecordConfigType struct { // internal + Settings SettingsType // fixed + Status statusType // changes for each update + History historyType // past information + sync.Mutex +} + +func (conf *RecordConfigType) String() string { + return conf.Settings.string() + ": " + conf.Status.string() + "; " + conf.History.string() +} + +func (conf *RecordConfigType) toHTML() HTMLRow { + row := HTMLRow{ + Domain: conf.Settings.getHTMLDomain(), + Host: conf.Settings.Host, + Provider: conf.Settings.getHTMLProvider(), + IPMethod: conf.Settings.getHTMLIPMethod(), + } + if conf.Status.Code == UPTODATE { + conf.Status.Message = "No IP change for " + durationString(conf.History.TSuccess) + } + row.Status = conf.Status.toHTML() + if len(conf.History.IPs) > 0 { + row.IP = "" + conf.History.IPs[0] + "" + } else { + row.IP = "N/A" + } + if len(conf.History.IPs) > 1 { + row.IPs = conf.History.IPs[1:] + for i := range row.IPs { + if i == len(row.IPs)-1 { + break + } + row.IPs[i] += ", " + } + } else { + row.IPs = []string{"N/A"} + } + return row +} diff --git a/pkg/models/settings.go b/pkg/models/settings.go new file mode 100644 index 000000000..953a4f80f --- /dev/null +++ b/pkg/models/settings.go @@ -0,0 +1,65 @@ +package models + +// SettingsType contains the elements to update the DNS record +type SettingsType struct { + Domain string + Host string + Provider string + IPmethod string + Password string +} + +func (settings *SettingsType) string() (s string) { + s = settings.Domain + "|" + settings.Host + "|" + settings.Provider + "|" + settings.IPmethod + "|" + for i := range settings.Password { + if i < 3 || i > len(settings.Password)-4 { + s += string(settings.Password[i]) + continue + } else if i < 8 { + s += "*" + } + } + return s +} + +// BuildDomainName builds the domain name from the domain and the host of the settings +func (settings *SettingsType) BuildDomainName() string { + if settings.Host == "@" { + return settings.Domain + } else if settings.Host == "*" { + return settings.Domain // TODO random subdomain + } else { + return settings.Host + "." + settings.Domain + } +} + +func (settings *SettingsType) getHTMLDomain() string { + return "" + settings.Domain + "" +} + +func (settings *SettingsType) getHTMLProvider() string { + switch settings.Provider { + case "namecheap": + return "Namecheap" + case "godaddy": + return "GoDaddy" + case "duckdns": + return "DuckDNS" + default: + return settings.Provider + } +} + +// TODO map to icons +func (settings *SettingsType) getHTMLIPMethod() string { + switch settings.IPmethod { + case "provider": + return settings.getHTMLProvider() + case "duckduckgo": + return "DuckDuckGo" + case "opendns": + return "OpenDNS" + default: + return settings.IPmethod + } +} diff --git a/pkg/models/status.go b/pkg/models/status.go new file mode 100644 index 000000000..7d745a99d --- /dev/null +++ b/pkg/models/status.go @@ -0,0 +1,67 @@ +package models + +import "time" + +type statusCode uint8 + +// Update possible status codes: FAIL, SUCCESS, UPTODATE or UPDATING +const ( + FAIL statusCode = iota + SUCCESS + UPTODATE + UPDATING +) + +func (code *statusCode) string() (s string) { + switch *code { + case SUCCESS: + return "Success" + case FAIL: + return "Failure" + case UPTODATE: + return "Up to date" + case UPDATING: + return "Already updating..." + default: + return "Unknown status code!" + } +} + +func (code *statusCode) toHTML() (s string) { + switch *code { + case SUCCESS: + return `Success` + case FAIL: + return `Failure` + case UPTODATE: + return `Up to date` + case UPDATING: + return `Already updating...` + default: + return `Unknown status code!` + } +} + +type statusType struct { + Code statusCode + Message string + Time time.Time +} + +func (status *statusType) string() (s string) { + s += status.Code.string() + if status.Message != "" { + s += " (" + status.Message + ")" + } + s += " at " + status.Time.Format("2006-01-02 15:04:05 MST") + return s +} + +func (status *statusType) toHTML() (s string) { + s += status.Code.toHTML() + if status.Message != "" { + s += " (" + status.Message + ")" + } + s += ", " + time.Since(status.Time).Round(time.Second).String() + " ago" + return s +} diff --git a/pkg/network/connectivity.go b/pkg/network/connectivity.go new file mode 100644 index 000000000..8ddf954b2 --- /dev/null +++ b/pkg/network/connectivity.go @@ -0,0 +1,70 @@ +package network + +import ( + "net" + "net/http" + "fmt" +) + + +// ConnectivityChecks verifies the connection to the domains in terms of DNS, HTTP and HTTPS +func ConnectivityChecks(client *http.Client, domains []string) (errs []error) { + chErrors := make(chan []error) + for _, domain := range domains { + go connectivityCheck(client, domain, chErrors) + } + N := len(domains) + for N > 0 { + select { + case errs := <- chErrors: + errs = append(errs, errs...) + N-- + } + } + close(chErrors) + return errs +} + +func connectivityCheck(client *http.Client, domain string, chErrors chan []error) { + var errs []error + chError := make(chan error) + go domainNameResolutionCheck(domain, chError) + go httpGetCheck(client, "http://"+domain, chError) + go httpGetCheck(client, "https://"+domain, chError) + N := 3 + for N > 0 { + select { + case err := <- chError: + if err != nil { + errs = append(errs, err) + } + N-- + } + } + close(chError) + chErrors <- errs +} + +func domainNameResolutionCheck(domain string, chError chan error) { + _, err := net.LookupIP(domain) + if err != nil { + chError <- fmt.Errorf("Domain name resolution is not working for %s: %s", domain, err) + return + } + chError <- nil +} + +func httpGetCheck(client *http.Client, URL string, chError chan error) { + req, err := http.NewRequest(http.MethodGet, URL, nil) + if err != nil { + chError <- fmt.Errorf("HTTP GET failed for %s: %s", URL, err) + return + } + statusCode, _, err := DoHTTPRequest(client, req) + if err != nil { + chError <- fmt.Errorf("HTTP GET failed for %s: %s", URL, err) + } else if statusCode != 200 { + chError <- fmt.Errorf("HTTP GET failed for %s: HTTP Status %d", URL, statusCode) + } + chError <- nil +} \ No newline at end of file diff --git a/pkg/network/constants.go b/pkg/network/constants.go new file mode 100644 index 000000000..0e1ae0f44 --- /dev/null +++ b/pkg/network/constants.go @@ -0,0 +1,3 @@ +package network + +const httpGetTimeout = 10000 // 10 seconds diff --git a/pkg/network/publicip.go b/pkg/network/publicip.go new file mode 100644 index 000000000..190ac958a --- /dev/null +++ b/pkg/network/publicip.go @@ -0,0 +1,20 @@ +package network + +import ( + "net/http" + "ddns-updater/pkg/regex" + "fmt" +) + +// GetPublicIP downloads a webpage and extracts the IP address from it +func GetPublicIP(client *http.Client, URL string) (ip string, err error) { + content, err := GetContent(client, URL) + if err != nil { + return ip, fmt.Errorf("cannot get public IP address from %s: %s", URL, err) + } + ip = regex.FindIP(string(content)) + if ip == "" { + return ip, fmt.Errorf("no public IP found at %s: %s", URL, err) + } + return ip, nil +} diff --git a/pkg/network/requests.go b/pkg/network/requests.go new file mode 100644 index 000000000..b4d12db59 --- /dev/null +++ b/pkg/network/requests.go @@ -0,0 +1,54 @@ +package network + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "fmt" +) + +// DoHTTPRequest performs an HTTP request and returns the status, content and eventual error +func DoHTTPRequest(client *http.Client, request *http.Request) (status int, content []byte, err error) { + response, err := client.Do(request) + if err != nil { + return status, nil, err + } + content, err = ioutil.ReadAll(response.Body) + response.Body.Close() + if err != nil { + return status, nil, err + } + return response.StatusCode, content, nil +} + +// GetContent returns the content and eventual error from an HTTP GET to a given URL +func GetContent(httpClient *http.Client, URL string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, URL, nil) + if err != nil { + return nil, fmt.Errorf("cannot GET content of URL %s: %s", URL, err) + } + status, content, err := DoHTTPRequest(httpClient, req) + if err != nil { + return nil, fmt.Errorf("cannot GET content of URL %s: %s", URL, err) + } + if status != 200 { + return nil, fmt.Errorf("cannot GET content of URL %s (status %d)", URL, status) + } + return content, nil +} + +// Used for GoDaddy only +func BuildHTTPPutJSONAuth(url, authorizationHeader string, body interface{}) (request *http.Request, err error) { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, err + } + request, err = http.NewRequest(http.MethodPut, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", authorizationHeader) + request.Header.Set("Content-Type", "application/json; charset=utf-8") + return request, nil +} diff --git a/pkg/params/init.go b/pkg/params/init.go new file mode 100644 index 000000000..080b10936 --- /dev/null +++ b/pkg/params/init.go @@ -0,0 +1,18 @@ +package params + +import ( + "github.com/spf13/viper" +) + +func init() { + viper.SetDefault("listeningport", "8000") + viper.SetDefault("rooturl", "/") + viper.SetDefault("delay", "300") + viper.SetDefault("data_dir", "") + viper.SetDefault("logging", "json") + viper.SetDefault("nodeid", "0") + viper.BindEnv("listeningport") + viper.BindEnv("rooturl") + viper.BindEnv("logging") + viper.BindEnv("nodeid") +} diff --git a/pkg/params/params.go b/pkg/params/params.go new file mode 100644 index 000000000..ed3e9b72a --- /dev/null +++ b/pkg/params/params.go @@ -0,0 +1,166 @@ +package params + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "time" + "fmt" + + "ddns-updater/pkg/logging" + "ddns-updater/pkg/models" + "ddns-updater/pkg/regex" + "github.com/spf13/viper" +) + +// GetListeningPort obtains and checks the listening port from Viper (env variable or config file, etc.) +func GetListeningPort() (listeningPort string) { + listeningPort = viper.GetString("listeningPort") + value, err := strconv.Atoi(listeningPort) + if err != nil { + logging.Fatal("listening port %s is not a valid integer", listeningPort) + } else if value < 1 { + logging.Fatal("listening port %s cannot be lower than 1", listeningPort) + } else if value < 1024 { + if os.Geteuid() == 0 { + logging.Warn("listening port %s allowed to be in the reserved system ports range as you are running as root", listeningPort) + } else if os.Geteuid() == -1 { + logging.Warn("listening port %s allowed to be in the reserved system ports range as you are running in Windows", listeningPort) + } else { + logging.Fatal("listening port %s cannot be in the reserved system ports range (1 to 1023) when running without root", listeningPort) + } + } else if value > 65535 { + logging.Fatal("listening port %s cannot be higher than 65535", listeningPort) + } else if value > 49151 { + // dynamic and/or private ports. + logging.Warn("listening port %s is in the dynamic/private ports range (above 49151)", listeningPort) + } else if value == 9999 { + logging.Fatal("listening port %s cannot be set to the local healthcheck port 9999", listeningPort) + } + return listeningPort +} + +// GetRootURL obtains and checks the root URL from Viper (env variable or config file, etc.) +func GetRootURL() (rootURL string) { + rootURL = viper.GetString("rooturl") + if strings.ContainsAny(rootURL, " .?~#") { + logging.Fatal("root URL %s contains invalid characters", rootURL) + } + if strings.HasSuffix(rootURL, "/") { + strings.TrimSuffix(rootURL, "/") + } + return rootURL +} + +// GetDelay obtains and delay duration between each updates from Viper (env variable or config file, etc.) +func GetDelay() time.Duration { + delayStr := viper.GetString("delay") + delayInt, err := strconv.ParseInt(delayStr, 10, 64) + if err != nil { + logging.Fatal("delay %s is not a valid integer", delayStr) + } + if delayInt < 10 { + logging.Fatal("delay %d must be bigger than 10 seconds", delayInt) + } + return time.Duration(delayInt) +} + +// GetDataDir obtains and data directory from Viper (env variable or config file, etc.) +func GetDataDir(dir string) string { + dataDir := viper.GetString("data_dir") + if len(dataDir) == 0 { + dataDir = dir + "/data" + } + return dataDir +} + +// GetDir obtains the executable directory +func GetDir() (dir string) { + ex, err := os.Executable() + if err != nil { + logging.Fatal("%s", err) + } + return filepath.Dir(ex) +} + +// GetLoggerMode obtains the logging mode from Viper (env variable or config file, etc.) +func GetLoggerMode() logging.Mode { + kind := viper.GetString("logging") + if kind == "json" { + return logging.JSON + } else if kind == "human" { + return logging.Human + } + return logging.Default +} + +// GetNodeID obtains the node instance ID from Viper (env variable or config file, etc.) +func GetNodeID() int { + nodeID := viper.GetString("nodeid") + value, err := strconv.Atoi(nodeID) + if err != nil { + logging.Fatal("Node ID %s is not a valid integer", nodeID) + } + return value +} + +// GetRecordConfigs get the DNS update configurations from the environment variables RECORD1, RECORD2, ... +func GetRecordConfigs() (recordsConfigs []models.RecordConfigType) { + var i uint64 = 1 + for { + config := os.Getenv(fmt.Sprintf("RECORD%d", i)) + if config == "" { + break + } + x := strings.Split(config, ",") + if len(x) != 5 { + logging.Fatal("The configuration entry %s should be in the format 'domain,host,provider,ipmethod,password'", config) + } + if !regex.Domain(x[0]) { + logging.Fatal("The domain name %s is not valid for entry %s", x[0], config) + } + if len(x[1]) == 0 { + logging.Fatal("The host for entry %s must have one character at least", config) + } // TODO test when it does not exist + if (x[2] == "duckdns" || x[2] == "dreamhost") && x[1] != "@" { + logging.Fatal("The host %s can only be '@' for the DuckDNS entry %s", x[1], config) + } + if x[2] != "namecheap" && x[2] != "godaddy" && x[2] != "duckdns" && x[2] != "dreamhost" { + logging.Fatal("The DNS provider %s is not supported for entry %s", x[2], config) + } + if x[2] == "namecheap" || x[2] == "duckdns" { + if x[3] != "duckduckgo" && x[3] != "opendns" && regex.FindIP(x[3]) == "" && x[3] != "provider" { + logging.Fatal("The IP query method %s is not valid for entry %s", x[3], config) + } + } else if x[3] != "duckduckgo" && x[3] != "opendns" && regex.FindIP(x[3]) == "" { + logging.Fatal("The IP query method %s is not valid for entry %s", x[3], config) + } + if x[2] == "namecheap" && !regex.NamecheapPassword(x[4]) { + logging.Fatal("The Namecheap password query parameter is not valid for entry %s", config) + } + if x[2] == "godaddy" && !regex.GodaddyKeySecret(x[4]) { + logging.Fatal("The GoDaddy password (key:secret) query parameter is not valid for entry %s", config) + } + if x[2] == "duckdns" && !regex.DuckDNSToken(x[4]) { + logging.Fatal("The DuckDNS password (token) query parameter is not valid for entry %s", config) + } + if x[2] == "dreamhost" && !regex.DreamhostKey(x[4]) { + logging.Fatal("The Dreamhost password (key) query parameter is not valid for entry %s", config) + } + recordsConfigs = append(recordsConfigs, models.RecordConfigType{ + Settings: models.SettingsType{ + Domain: x[0], + Host: x[1], + Provider: x[2], + IPmethod: x[3], + Password: x[4], + }, + }) + i++ + } + if len(recordsConfigs) == 0 { + logging.Fatal("No record to update was found in the environment variable RECORD1") + } + return recordsConfigs +} diff --git a/pkg/regex/regex.go b/pkg/regex/regex.go new file mode 100644 index 000000000..2de7a1b9c --- /dev/null +++ b/pkg/regex/regex.go @@ -0,0 +1,20 @@ +package regex + +import ( + "regexp" +) + +// Regex FindString functions +var ( + FindIP = regexp.MustCompile(`(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}`).FindString +) + +// Regex MatchString functions +var ( + IP = regexp.MustCompile(`(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}`).MatchString + Domain = regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z]{2,3})$`).MatchString + GodaddyKeySecret = regexp.MustCompile(`^[A-Za-z0-9]{12}\_[A-Za-z0-9]{22}\:[A-Za-z0-9]{22}$`).MatchString + DuckDNSToken = regexp.MustCompile(`^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$`).MatchString + NamecheapPassword = regexp.MustCompile(`^[a-f0-9]{32}$`).MatchString + DreamhostKey = regexp.MustCompile(`^[A-Z0-9]{16}$`).MatchString +) \ No newline at end of file diff --git a/updater/publicip_test.go b/pkg/regex/regex_test.go similarity index 62% rename from updater/publicip_test.go rename to pkg/regex/regex_test.go index cbf1a57ee..812a3c0f7 100644 --- a/updater/publicip_test.go +++ b/pkg/regex/regex_test.go @@ -1,10 +1,10 @@ -package main +package regex import ( "testing" ) -func Test_regexIP(t *testing.T) { +func Test_IP(t *testing.T) { cases := []struct { s string ip string @@ -15,9 +15,9 @@ func Test_regexIP(t *testing.T) { }, } for _, c := range cases { - out := regexIP(c.s) + out := FindIP(c.s) if out != c.ip { - t.Errorf("regexIP(%s) == %s want %s", c.s, out, c.ip) + t.Errorf("FindIP(%s) == %s want %s", c.s, out, c.ip) } } } diff --git a/pkg/server/serve.go b/pkg/server/serve.go new file mode 100644 index 000000000..b911c27c8 --- /dev/null +++ b/pkg/server/serve.go @@ -0,0 +1,55 @@ +package server + +import ( + "fmt" + "net/http" + "text/template" + + "ddns-updater/pkg/logging" + "ddns-updater/pkg/models" + "github.com/julienschmidt/httprouter" +) + +type indexParamsType struct { + dir string + recordsConfigs []models.RecordConfigType +} + +type updateParamsType struct { + rootURL string + forceCh chan struct{} +} + +// CreateRouter returns a router with all the necessary routes configured +func CreateRouter(rootURL, dir string, forceCh chan struct{}, recordsConfigs []models.RecordConfigType) *httprouter.Router { + indexParams := indexParamsType{ + dir: dir, + recordsConfigs: recordsConfigs, + } + updateParams := updateParamsType{ + rootURL: rootURL, + forceCh: forceCh, + } + router := httprouter.New() + router.GET(rootURL+"/", indexParams.get) + router.GET(rootURL+"/update", updateParams.get) + return router +} + +func (params *indexParamsType) get(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + // TODO: Forms to change existing updates or add some + t := template.Must(template.ParseFiles(params.dir + "/ui/index.html")) + htmlData := models.ToHTML(params.recordsConfigs) + err := t.ExecuteTemplate(w, "index.html", htmlData) // TODO Without pointer? + if err != nil { + logging.Warn("%s", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "An error occurred creating this webpage") + } +} + +func (params *updateParamsType) get(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + params.forceCh <- struct{}{} + logging.Info("Update started manually") + http.Redirect(w, r, params.rootURL, 301) +} diff --git a/pkg/update/trigger.go b/pkg/update/trigger.go new file mode 100644 index 000000000..83689ad9b --- /dev/null +++ b/pkg/update/trigger.go @@ -0,0 +1,52 @@ +package update + +import ( + "time" + "net/http" + "ddns-updater/pkg/logging" + "ddns-updater/pkg/models" + "ddns-updater/pkg/database" +) + +// TriggerServer runs an infinite asynchronous periodic function that triggers updates +func TriggerServer( + delay time.Duration, + forceCh, quitCh chan struct{}, + recordsConfigs []models.RecordConfigType, + httpClient *http.Client, + sqlDb *database.DB, +) { + ticker := time.NewTicker(delay * time.Second) + defer func() { + ticker.Stop() + close(quitCh) + }() + for { + select { + case <-ticker.C: + for i := range recordsConfigs { + go update(&recordsConfigs[i], httpClient, sqlDb) + } + case <-forceCh: + for i := range recordsConfigs { + go update(&recordsConfigs[i], httpClient, sqlDb) + } + case <-quitCh: + for { + allUpdatesFinished := true + for i := range recordsConfigs { + if recordsConfigs[i].Status.Code == models.UPDATING { + allUpdatesFinished = false + } + } + if allUpdatesFinished { + break + } + logging.Info("Waiting for updates to complete...") + time.Sleep(400 * time.Millisecond) + } + ticker.Stop() + return + } + } +} \ No newline at end of file diff --git a/pkg/update/update.go b/pkg/update/update.go new file mode 100644 index 000000000..351b1f515 --- /dev/null +++ b/pkg/update/update.go @@ -0,0 +1,388 @@ +package update + +import ( + "encoding/json" + "encoding/xml" + "log" + "net/http" + "strings" + "time" + "fmt" + + "ddns-updater/pkg/logging" + "ddns-updater/pkg/database" + "ddns-updater/pkg/models" + "ddns-updater/pkg/network" + "ddns-updater/pkg/regex" + uuid "github.com/google/uuid" +) + +const ( + namecheapURL = "https://dynamicdns.park-your-domain.com/update" + godaddyURL = "https://api.godaddy.com/v1/domains" + duckdnsURL = "https://www.duckdns.org/update" + dreamhostURL = "https://api.dreamhost.com" +) + +type goDaddyPutBody struct { + Data string `json:"data"` // IP address to update to +} + +type dreamhostList struct { + Result string `json:"result"` + Data []dreamhostData `json:"data"` +} + +type dreamhostData struct { + Editable string `json:"editable"` + Type string `json:"type"` + Record string `json:"record"` + Value string `json:"value"` +} + +type dreamhostReponse struct { + Result string `json:"result"` + Data string `json:"data"` +} + +func update( + recordConfig *models.RecordConfigType, + httpClient *http.Client, + sqlDb *database.DB, +) { + recordConfig.Lock() + defer recordConfig.Unlock() + if recordConfig.Status.Code == models.UPDATING { + logging.Info(recordConfig.String()) + return + } + recordConfig.Status.Code = models.UPDATING + defer func() { + if recordConfig.Status.Code == models.UPDATING { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "Status not changed from UPDATING" + } + }() + recordConfig.Status.Time = time.Now() + + // Get the public IP address + var ip string + var err error + if recordConfig.Settings.IPmethod == "provider" { + ip = "" + } else if recordConfig.Settings.IPmethod == "duckduckgo" { + ip, err = network.GetPublicIP(httpClient, "https://duckduckgo.com/?q=ip") + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + } else if recordConfig.Settings.IPmethod == "opendns" { + ip, err = network.GetPublicIP(httpClient, "https://diagnostic.opendns.com/myip") + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + } else { // fixed IP + ip = recordConfig.Settings.IPmethod + } + if ip != "" && len(recordConfig.History.IPs) > 0 && ip == recordConfig.History.IPs[0] { // same IP + recordConfig.Status.Code = models.UPTODATE + recordConfig.Status.Message = "No IP change for " + time.Since(recordConfig.History.TSuccess).Round(time.Second).String() + return + } + + // Update the record + if recordConfig.Settings.Provider == "namecheap" { + url := namecheapURL + "?host=" + strings.ToLower(recordConfig.Settings.Host) + + "&domain=" + strings.ToLower(recordConfig.Settings.Domain) + "&password=" + strings.ToLower(recordConfig.Settings.Password) + if ip != "provider" { + url += "&ip=" + ip + } + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err := network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { // TODO test / combine with below + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("%s responded with status %d", r.URL.String(), status) + log.Println(recordConfig.String()) + return + } + var parsedXML struct { + Errors struct { + Error string `xml:"Err1"` + } `xml:"errors"` + IP string `xml:"IP"` + } + err = xml.Unmarshal(content, &parsedXML) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if parsedXML.Errors.Error != "" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = parsedXML.Errors.Error + log.Println(recordConfig.String()) + return + } + if parsedXML.IP == "" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "No IP address was sent back from DDNS server" + log.Println(recordConfig.String()) + return + } + if regex.FindIP(parsedXML.IP) == "" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "IP address " + parsedXML.IP + " is not valid" + log.Println(recordConfig.String()) + return + } + ip = parsedXML.IP + } else if recordConfig.Settings.Provider == "godaddy" { + url := godaddyURL + "/" + strings.ToLower(recordConfig.Settings.Domain) + "/records/A/" + strings.ToLower(recordConfig.Settings.Host) + r, err := network.BuildHTTPPutJSONAuth( + url, + "sso-key "+recordConfig.Settings.Password, // password is key:secret here + []goDaddyPutBody{ + goDaddyPutBody{ + ip, + }, + }, + ) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err := network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("HTTP %d", status) + var parsedJSON struct { + Message string `json:"message"` + } + err = json.Unmarshal(content, &parsedJSON) + if err != nil { + recordConfig.Status.Message = err.Error() + } else if parsedJSON.Message != "" { + recordConfig.Status.Message += " - " + parsedJSON.Message + } + log.Println(recordConfig.String()) + return + } + } else if recordConfig.Settings.Provider == "duckdns" { + url := duckdnsURL + "?domains=" + strings.ToLower(recordConfig.Settings.Domain) + + "&token=" + recordConfig.Settings.Password + "&verbose=true" + if ip != "provider" { + url += "&ip=" + ip + } + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err := network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("HTTP %d", status) + log.Println(recordConfig.String()) + return + } + s := string(content) + if s[0:2] == "KO" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "Bad DuckDNS domain/token combination" + log.Println(recordConfig.String()) + return + } else if s[0:2] == "OK" { + ip = regex.FindIP(s) + if ip == "" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "DuckDNS did not respond with an IP address" + log.Println(recordConfig.String()) + return + } + } else { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "DuckDNS responded with '" + s + "'" + log.Println(recordConfig.String()) + return + } + } else if recordConfig.Settings.Provider == "dreamhost" { + url := dreamhostURL + "/?key=" + recordConfig.Settings.Password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records" + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err := network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("HTTP %d", status) + log.Println(recordConfig.String()) + return + } + var dhList dreamhostList + err = json.Unmarshal(content, &dhList) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } else if dhList.Result != "success" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = dhList.Result + log.Println(recordConfig.String()) + return + } + var oldIP string + var found bool + for _, data := range dhList.Data { + if data.Type == "A" && data.Record == recordConfig.Settings.BuildDomainName() { + if data.Editable == "0" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "Record data is not editable" + log.Println(recordConfig.String()) + return + } + oldIP := data.Value + if oldIP == ip { + recordConfig.Status.Code = models.UPTODATE + recordConfig.Status.Message = "No IP change for " + time.Since(recordConfig.History.TSuccess).Round(time.Second).String() + return + } + found = true + break + } + } + if found { + url = dreamhostURL + "?key=" + recordConfig.Settings.Password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + strings.ToLower(recordConfig.Settings.Domain) + "&type=A&value=" + oldIP + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err = network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("HTTP %d", status) + log.Println(recordConfig.String()) + return + } + var dhResponse dreamhostReponse + err = json.Unmarshal(content, &dhResponse) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } else if dhResponse.Result != "success" { // this should not happen + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = dhResponse.Result + " - " + dhResponse.Data + log.Println(recordConfig.String()) + return + } + } + url = dreamhostURL + "?key=" + recordConfig.Settings.Password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + strings.ToLower(recordConfig.Settings.Domain) + "&type=A&value=" + ip + r, err = http.NewRequest(http.MethodGet, url, nil) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + status, content, err = network.DoHTTPRequest(httpClient, r) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } + if status != 200 { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = fmt.Sprintf("HTTP %d", status) + log.Println(recordConfig.String()) + return + } + var dhResponse dreamhostReponse + err = json.Unmarshal(content, &dhResponse) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = err.Error() + log.Println(recordConfig.String()) + return + } else if dhResponse.Result != "success" { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = dhResponse.Result + " - " + dhResponse.Data + log.Println(recordConfig.String()) + return + } + } + if len(recordConfig.History.IPs) > 0 && ip == recordConfig.History.IPs[0] { // same IP + recordConfig.Status.Code = models.UPTODATE + recordConfig.Status.Message = "No IP change for " + time.Since(recordConfig.History.TSuccess).Round(time.Second).String() + err = sqlDb.UpdateIPTime(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "Cannot update database: " + err.Error() + } + return + } + // new IP + recordConfig.Status.Code = models.SUCCESS + recordConfig.Status.Message = "" + recordConfig.History.TSuccess = time.Now() + recordConfig.History.IPs = append([]string{ip}, recordConfig.History.IPs...) + err = sqlDb.StoreNewIP(recordConfig.Settings.Domain, recordConfig.Settings.Host, ip) + if err != nil { + recordConfig.Status.Code = models.FAIL + recordConfig.Status.Message = "Cannot update database: " + err.Error() + } +} diff --git a/updater/ui/favicon.ico b/ui/favicon.ico similarity index 100% rename from updater/ui/favicon.ico rename to ui/favicon.ico diff --git a/updater/ui/index.html b/ui/index.html similarity index 93% rename from updater/ui/index.html rename to ui/index.html index 46ab04c92..11dd12819 100644 --- a/updater/ui/index.html +++ b/ui/index.html @@ -54,7 +54,7 @@