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 @@ Set IP Previous IPs (achronologically) - {{range .Updates}} + {{range .Rows}} {{.Domain}} {{.Host}} @@ -71,7 +71,7 @@ {{end}}
- Made by Quentin McGaw + Made by Quentin McGaw
github.com/qdm12/ddns-updater diff --git a/updater/db.go b/updater/db.go deleted file mode 100644 index 7241df4b4..000000000 --- a/updater/db.go +++ /dev/null @@ -1,120 +0,0 @@ -package main - -import ( - "database/sql" - "strings" - "sync" - "time" - - _ "github.com/mattn/go-sqlite3" -) - -// 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 -type DB struct { - db *sql.DB - m sync.Mutex -} - -// initializes the database schema if it does not exist yet. -func initializeDatabase(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: db}, err -} - -func (dbContainer *DB) updateIPTime(domain, host, ip string) (err error) { - dbContainer.m.Lock() - _, err = dbContainer.db.Exec( - `UPDATE updates_ips - SET t_last = ? - WHERE domain = ? AND host = ? AND ip = ? AND current = 1`, - time.Now(), - domain, - host, - ip, - ) - dbContainer.m.Unlock() - return err -} - -func (dbContainer *DB) storeNewIP(domain, host, ip string) (err error) { - // Disable the current IP - dbContainer.m.Lock() - _, err = dbContainer.db.Exec( - `UPDATE updates_ips - SET current = 0 - WHERE domain = ? AND host = ? AND current = 1`, - domain, - host, - ) - dbContainer.m.Unlock() - if err != nil { - return err - } - // Inserts new IP - dbContainer.m.Lock() - _, err = dbContainer.db.Exec( - `INSERT INTO updates_ips(domain,host,ip,t_new,t_last,current) - VALUES(?, ?, ?, ?, ?, ?);`, - domain, - host, - ip, - time.Now(), - time.Now(), - 1, - ) - dbContainer.m.Unlock() - return err -} - -func (dbContainer *DB) getIps(domain, host string) (ips []string, tNew time.Time, err error) { - dbContainer.m.Lock() - rows, err := dbContainer.db.Query( - `SELECT ip, t_new - FROM updates_ips - WHERE domain = ? AND host = ? - ORDER BY t_new DESC`, - domain, - host, - ) - dbContainer.m.Unlock() - 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/updater/go.mod b/updater/go.mod deleted file mode 100644 index 47abede02..000000000 --- a/updater/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/qdm12/ddns-updater/updater - -require ( - github.com/google/uuid v1.1.0 - github.com/julienschmidt/httprouter v1.2.0 - github.com/kyokomi/emoji v1.5.1 - github.com/mattn/go-sqlite3 v1.10.0 -) diff --git a/updater/go.sum b/updater/go.sum deleted file mode 100644 index c434dab00..000000000 --- a/updater/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -github.com/google/uuid v1.1.0 h1:Jf4mxPC/ziBnoPIdpQdPJ9OeiomAUHLvxmPRSPH9m4s= -github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -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 v1.5.1 h1:qp9dub1mW7C4MlvoRENH6EAENb9skEFOvIEbp1Waj38= -github.com/kyokomi/emoji v1.5.1/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= -github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= diff --git a/updater/html.go b/updater/html.go deleted file mode 100644 index 88d198293..000000000 --- a/updater/html.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "strconv" - "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 { - Updates []UpdateType -} - -// UpdateType contains HTML fields to be rendered -// It is exported so that the HTML template engine can render it. -type UpdateType struct { - Domain string - Host string - Provider string - IPMethod string - Status string - IP string // current set ip - IPs []string // previous ips -} - -func durationString(t time.Time) (durationStr string) { - duration := time.Since(t) - if duration < time.Minute { - return strconv.FormatFloat(duration.Round(time.Second).Seconds(), 'f', -1, 64) + "s" - } else if duration < time.Hour { - return strconv.FormatFloat(duration.Round(time.Minute).Minutes(), 'f', -1, 64) + "m" - } else if duration < 24*time.Hour { - return strconv.FormatFloat(duration.Round(time.Hour).Hours(), 'f', -1, 64) + "h" - } else { - return strconv.FormatFloat(duration.Round(time.Hour*24).Hours()/24, 'f', -1, 64) + "d" - } -} - -func (updates updatesType) toHTML() (htmlData *HTMLData) { - htmlData = new(HTMLData) - for i := range updates { - u := &updates[i] - var U UpdateType - U.Domain = u.settings.htmlDomain() - U.Host = u.settings.host // TODO html method - U.Provider = u.settings.htmlProvider() - U.IPMethod = u.settings.htmlIpmethod() - if u.status.code == UPTODATE { - u.status.message = "No IP change for " + durationString(u.extras.tSuccess) - } - U.Status = u.status.html() - if len(u.extras.ips) > 0 { - U.IP = "" + u.extras.ips[0] + "" - } else { - U.IP = "N/A" - } - if len(u.extras.ips) > 1 { - U.IPs = u.extras.ips[1:] - for i := range U.IPs { - if i == len(U.IPs)-1 { - break - } - U.IPs[i] += ", " - } - } else { - U.IPs = []string{"N/A"} - } - htmlData.Updates = append(htmlData.Updates, U) - } - return htmlData -} diff --git a/updater/main.go b/updater/main.go deleted file mode 100644 index 9ceceef01..000000000 --- a/updater/main.go +++ /dev/null @@ -1,196 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net" - "net/http" - "os" - "path/filepath" - "text/template" - "time" - - "github.com/julienschmidt/httprouter" - "github.com/kyokomi/emoji" -) - -type updatesType []updateType -type envType struct { - fsLocation string - rootURL string - delay time.Duration - updates updatesType - forceCh chan struct{} - quitCh chan struct{} - dbContainer *DB - httpClient *http.Client -} - -func healthcheckMode() bool { - args := os.Args - if len(args) > 1 { - if len(args) > 2 { - log.Fatal(emoji.Sprint(":x:") + " Too many arguments provided") - } - if args[1] == "healthcheck" { - return true - } - log.Fatal(emoji.Sprint(":x:") + " Argument 1 can only be 'healthcheck', not " + args[1]) - } - return false -} - -func healthcheck(listeningPort, rootURL string) { - request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+listeningPort+rootURL+"healthcheck", nil) - if err != nil { - fmt.Println("Can't build HTTP request") - os.Exit(1) - } - client := &http.Client{Timeout: time.Duration(1000) * time.Millisecond} - response, err := client.Do(request) - if err != nil { - fmt.Println("Can't execute HTTP request") - os.Exit(1) - } - if response.StatusCode != 200 { - fmt.Println("Status code is " + response.Status) - os.Exit(1) - } - os.Exit(0) -} - -func main() { - listeningPort := getListeningPort() - if healthcheckMode() { - rootURL := getRootURL() - healthcheck(listeningPort, rootURL) - } - 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") - var env envType - env.rootURL = getRootURL() - env.delay = getDelay() - env.updates = getUpdates() - env.forceCh = make(chan struct{}) - env.quitCh = make(chan struct{}) - ex, err := os.Executable() - if err != nil { - log.Fatal(err) - } - env.fsLocation = filepath.Dir(ex) - dataDir := getDataDir(env.fsLocation) - env.dbContainer, err = initializeDatabase(dataDir) - if err != nil { - log.Fatal(err) - } - env.httpClient = &http.Client{Timeout: time.Duration(httpGetTimeout) * time.Millisecond} - for i := range env.updates { - u := &env.updates[i] - var err error - u.m.Lock() - u.extras.ips, u.extras.tSuccess, err = env.dbContainer.getIps(u.settings.domain, u.settings.host) - u.m.Unlock() - if err != nil { - log.Fatal(err) - } - } - connectivityTest(env.httpClient) - go triggerUpdates(&env) - env.forceCh <- struct{}{} - router := httprouter.New() - router.GET(env.rootURL, env.getIndex) - router.GET(env.rootURL+"update", env.getUpdate) - router.GET(env.rootURL+"healthcheck", env.getHealthcheck) - log.Println("Web UI listening on 0.0.0.0:" + listeningPort + emoji.Sprint(" :ear:")) - log.Fatal(http.ListenAndServe("0.0.0.0:"+listeningPort, router)) -} - -func triggerUpdates(env *envType) { - ticker := time.NewTicker(env.delay * time.Second) - defer func() { - ticker.Stop() - close(env.quitCh) - }() - for { - select { - case <-ticker.C: - for i := range env.updates { - go env.update(i) - } - case <-env.forceCh: - for i := range env.updates { - go env.update(i) - } - case <-env.quitCh: - for { - allUpdatesFinished := true - for i := range env.updates { - u := &env.updates[i] - if u.status.code == UPDATING { - allUpdatesFinished = false - } - } - if allUpdatesFinished { - break - } - log.Println("Waiting for updates to complete...") - time.Sleep(time.Duration(400) * time.Millisecond) - } - ticker.Stop() - return - } - } -} - -func (env *envType) getIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - // TODO: Forms to change existing updates or add some - htmlData := env.updates.toHTML() - t := template.Must(template.ParseFiles(env.fsLocation + "/ui/index.html")) - err := t.ExecuteTemplate(w, "index.html", htmlData) // TODO Without pointer? - if err != nil { - log.Println(err) - fmt.Fprint(w, "An error occurred creating this webpage: "+err.Error()) - } -} - -func (env *envType) getUpdate(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - env.forceCh <- struct{}{} - log.Println("Update started manually " + emoji.Sprint(":repeat:")) - http.Redirect(w, r, env.rootURL, 301) -} - -func (env *envType) getHealthcheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - for i := range env.updates { - u := &env.updates[i] - if u.status.code == FAIL { - log.Println("Responded with error to Healthcheck (" + u.String() + ")") - w.WriteHeader(http.StatusInternalServerError) - return - } - if u.status.code != UPDATING { - ips, err := net.LookupIP(u.settings.buildDomainName()) - if err != nil { - log.Println("Responded with error to Healthcheck (" + err.Error() + ")") - w.WriteHeader(http.StatusInternalServerError) - return - } - if len(u.extras.ips) == 0 { - log.Println("Responded with error to Healthcheck (No set IP address found)") - w.WriteHeader(http.StatusInternalServerError) - return - } - for i := range ips { - if ips[i].String() != u.extras.ips[0] { - log.Println("Responded with error to Healthcheck (Lookup IP address of " + u.settings.buildDomainName() + " is not equal to " + u.extras.ips[0] + ")") - w.WriteHeader(http.StatusInternalServerError) - return - } - } - } - } - w.WriteHeader(http.StatusOK) -} diff --git a/updater/network.go b/updater/network.go deleted file mode 100644 index 0228fac9e..000000000 --- a/updater/network.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "log" - "net" - "net/http" - "strconv" - - "github.com/kyokomi/emoji" -) - -const httpGetTimeout = 10000 // 10 seconds - -func connectivityTest(client *http.Client) { - _, err := net.LookupIP("google.com") - if err != nil { - log.Println(emoji.Sprint(":signal_strength:") + "Domain name resolution " + emoji.Sprint(":x:") + " is not working for google.com (" + err.Error() + ")") - } else { - log.Println(emoji.Sprint(":signal_strength:") + "Domain name resolution " + emoji.Sprint(":heavy_check_mark:")) - } - req, err := http.NewRequest(http.MethodGet, "https://google.com", nil) - if err != nil { - log.Println(emoji.Sprint(":signal_strength:") + "HTTP GET " + emoji.Sprint(":x:") + " " + err.Error()) - } - status, _, err := doHTTPRequest(client, req) - if err != nil { - log.Println(emoji.Sprint(":signal_strength:") + "HTTP GET " + emoji.Sprint(":x:") + " " + err.Error()) - } else if status != "200" { - log.Println(emoji.Sprint(":signal_strength:") + "HTTP GET " + emoji.Sprint(":x:") + " HTTP status " + status) - } else { - log.Println(emoji.Sprint(":signal_strength:") + "HTTP GET " + emoji.Sprint(":heavy_check_mark:")) - } -} - -// GoDaddy -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 -} - -func doHTTPRequest(client *http.Client, request *http.Request) (status string, content []byte, err error) { - response, err := client.Do(request) - if err != nil { - return status, nil, err - } - status = strconv.FormatInt(int64(response.StatusCode), 10) - content, err = ioutil.ReadAll(response.Body) - response.Body.Close() - if err != nil { - return status, nil, err - } - return status, content, nil -} diff --git a/updater/params.go b/updater/params.go deleted file mode 100644 index 0698b5d5b..000000000 --- a/updater/params.go +++ /dev/null @@ -1,146 +0,0 @@ -package main - -import ( - "log" - "os" - "regexp" - "strconv" - "strings" - "time" - - "github.com/kyokomi/emoji" -) - -const ( - defaultListeningPort = "8000" - defaultRootURL = "/" - defaultDelay = time.Duration(300) -) - -var regexDomain = 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 -var regexGodaddyKeySecret = regexp.MustCompile(`^[A-Za-z0-9]{12}\_[A-Za-z0-9]{22}\:[A-Za-z0-9]{22}$`).MatchString -var regexDuckDNSToken = regexp.MustCompile(`^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$`).MatchString -var regexNamecheapPassword = regexp.MustCompile(`^[a-f0-9]{32}$`).MatchString -var regexDreamhostKey = regexp.MustCompile(`^[A-Z0-9]{16}$`).MatchString - -func getListeningPort() (listeningPort string) { - listeningPort = os.Getenv("LISTENINGPORT") - if len(listeningPort) == 0 { - listeningPort = defaultListeningPort - } else { - value, err := strconv.Atoi(listeningPort) - if err != nil { - log.Fatal(emoji.Sprint(":x:") + " LISTENINGPORT environment variable '" + listeningPort + "' is not a valid integer") - } else if value < 1 { - log.Fatal(emoji.Sprint(":x:") + " LISTENINGPORT environment variable '" + listeningPort + "' can't be lower than 1") - } else if value < 1024 { - if os.Geteuid() == 0 { - log.Println(emoji.Sprint(":warning:") + "LISTENINGPORT environment variable '" + listeningPort + "' allowed to be in the reserved system ports range as you are running as root.") - } else if os.Geteuid() == -1 { - log.Println(emoji.Sprint(":warning:") + "LISTENINGPORT environment variable '" + listeningPort + "' allowed to be in the reserved system ports range as you are running in Windows.") - } else { - log.Fatal(emoji.Sprint(":x:") + " LISTENINGPORT environment variable '" + listeningPort + "' can't be in the reserved system ports range (1 to 1023) when running without root.") - } - } else if value > 65535 { - log.Fatal(emoji.Sprint(":x:") + " LISTENINGPORT environment variable '" + listeningPort + "' can't be higher than 65535") - } else if value > 49151 { - // dynamic and/or private ports. - log.Println(emoji.Sprint(":warning:") + "LISTENINGPORT environment variable '" + listeningPort + "' is in the dynamic/private ports range (above 49151)") - } - } - return listeningPort -} - -func getRootURL() (rootURL string) { - rootURL = os.Getenv("ROOTURL") - if len(rootURL) == 0 { - rootURL = defaultRootURL - } else if strings.ContainsAny(rootURL, " .?~#") { - log.Fatal(emoji.Sprint(":x:") + " ROOTURL environment variable '" + rootURL + "' contains invalid characters") - } - if rootURL[len(rootURL)-1] != '/' { - rootURL += "/" - } - return rootURL -} - -func getDelay() (delay time.Duration) { - delayStr := os.Getenv("DELAY") - if len(delayStr) == 0 { - delay = defaultDelay - } else { - delayUint, err := strconv.ParseUint(delayStr, 10, 64) - if err != nil { - log.Fatal(emoji.Sprint(":x:") + " DELAY environment variable '" + delayStr + "' is not a valid positive integer") - } - delay = time.Duration(int64(delayUint)) - } - return delay -} - -func getDataDir(fsLocation string) (dataDir string) { - dataDir = os.Getenv("DATA_DIR") - if len(dataDir) == 0 { - dataDir = fsLocation + "/data" - } - return dataDir -} - -func getUpdates() (updates []updateType) { - var i uint64 = 1 - for { - config := os.Getenv("RECORD" + strconv.FormatUint(i, 10)) - if config == "" { - break - } - x := strings.Split(config, ",") - if len(x) != 5 { - log.Fatal(emoji.Sprint(":x:") + " The configuration entry '" + config + "' should be in the format 'domain,host,provider,ipmethod,password'") - } - if !regexDomain(x[0]) { - log.Fatal(emoji.Sprint(":x:") + " The domain name '" + x[0] + "' is not valid for entry '" + config + "'") - } - if len(x[1]) == 0 { - log.Fatal(emoji.Sprint(":x:") + " The host for entry '" + config + "' must have one character at least") - } // TODO test when it does not exist - if (x[2] == "duckdns" || x[2] == "dreamhost") && x[1] != "@" { - log.Fatal(emoji.Sprint(":x:") + " The host '" + x[1] + "' can only be '@' for the DuckDNS entry '" + config + "'") - } - if x[2] != "namecheap" && x[2] != "godaddy" && x[2] != "duckdns" && x[2] != "dreamhost" { - log.Fatal(emoji.Sprint(":x:") + " The DNS provider '" + x[2] + "' is not supported for entry '" + config + "'") - } - if x[2] == "namecheap" || x[2] == "duckdns" { - if x[3] != "duckduckgo" && x[3] != "opendns" && regexIP(x[3]) == "" && x[3] != "provider" { - log.Fatal(emoji.Sprint(":x:") + " The IP query method '" + x[3] + "' is not valid for entry '" + config + "'") - } - } else if x[3] != "duckduckgo" && x[3] != "opendns" && regexIP(x[3]) == "" { - log.Fatal(emoji.Sprint(":x:") + " The IP query method '" + x[3] + "' is not valid for entry '" + config + "'") - } - if x[2] == "namecheap" && !regexNamecheapPassword(x[4]) { - log.Fatal(emoji.Sprint(":x:") + " The Namecheap password query parameter is not valid for entry '" + config + "'") - } - if x[2] == "godaddy" && !regexGodaddyKeySecret(x[4]) { - log.Fatal(emoji.Sprint(":x:") + " The GoDaddy password (key:secret) query parameter is not valid for entry '" + config + "'") - } - if x[2] == "duckdns" && !regexDuckDNSToken(x[4]) { - log.Fatal(emoji.Sprint(":x:") + " The DuckDNS password (token) query parameter is not valid for entry '" + config + "'") - } - if x[2] == "dreamhost" && !regexDreamhostKey(x[4]) { - log.Fatal(emoji.Sprint(":x:") + " The Dreamhost password (key) query parameter is not valid for entry '" + config + "'") - } - updates = append(updates, updateType{ - settings: updateSettings{ - domain: x[0], - host: x[1], - provider: x[2], - ipmethod: x[3], - password: x[4], - }, - }) - i++ - } - if len(updates) == 0 { - log.Fatal(emoji.Sprint(":x:") + " No record to update was found in the environment variable RECORD1") - } - return updates -} diff --git a/updater/publicip.go b/updater/publicip.go deleted file mode 100644 index 4a7b5d2b3..000000000 --- a/updater/publicip.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "errors" - "net/http" - "regexp" -) - -var regexIP = 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 - -func getPublicIP(client *http.Client, address string) (ip string, err error) { - r, err := http.NewRequest(http.MethodGet, address, nil) - if err != nil { - return ip, err - } - status, content, err := doHTTPRequest(client, r) - if err != nil { - return ip, err - } - if status != "200" { - return ip, errors.New(address + " responded with a status " + status) - } - ip = regexIP(string(content)) - if ip == "" { - return ip, errors.New("No public IP found at " + address) - } - return ip, nil -} diff --git a/updater/structures.go b/updater/structures.go deleted file mode 100644 index 15381eb04..000000000 --- a/updater/structures.go +++ /dev/null @@ -1,160 +0,0 @@ -package main - -import ( - "sync" - "time" -) - -type updateSettings struct { - domain string - host string - provider string - ipmethod string - password string -} - -func (u *updateSettings) String() (s string) { - s = u.domain + "|" + u.host + "|" + u.provider + "|" + u.ipmethod + "|" - for i := range u.password { - if i < 3 || i > len(u.password)-4 { - s += string(u.password[i]) - continue - } else if i < 8 { - s += "*" - } - } - return s -} - -func (u *updateSettings) buildDomainName() string { - if u.host == "@" { - return u.domain - } else if u.host == "*" { - return u.domain // TODO random subdomain - } else { - return u.host + "." + u.domain - } -} - -func (u *updateSettings) htmlDomain() string { - return "" + u.domain + "" -} - -func (u *updateSettings) htmlProvider() string { - switch u.provider { - case "namecheap": - return "Namecheap" - case "godaddy": - return "GoDaddy" - case "duckdns": - return "DuckDNS" - default: - return u.provider - } -} - -// TODO map to icons -func (u *updateSettings) htmlIpmethod() string { - switch u.ipmethod { - case "provider": - return u.htmlProvider() - case "duckduckgo": - return "DuckDuckGo" - case "opendns": - return "OpenDNS" - default: - return u.ipmethod - } -} - -type statusCode uint8 - -func (c *statusCode) String() (s string) { - switch *c { - 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 (c *statusCode) html() (s string) { - switch *c { - case SUCCESS: - return `Success` - case FAIL: - return `Failure` - case UPTODATE: - return `Up to date` - case UPDATING: - return `Already updating...` - default: - return `Unknown status code!` - } -} - -const ( - FAIL statusCode = iota - SUCCESS - UPTODATE - UPDATING -) - -type updateStatus struct { - code statusCode - message string - time time.Time -} - -func (u *updateStatus) String() (s string) { - s += u.code.String() - if u.message != "" { - s += " (" + u.message + ")" - } - s += " at " + u.time.Format("2006-01-02 15:04:05 MST") - return s -} - -func (u *updateStatus) html() (s string) { - s += u.code.html() - if u.message != "" { - s += " (" + u.message + ")" - } - s += ", " + time.Since(u.time).Round(time.Second).String() + " ago" - return s -} - -type updateExtras struct { - ips []string // current and previous ips - tSuccess time.Time -} - -func (u *updateExtras) String() (s string) { - if len(u.ips) > 0 { - s += "Last success update: " + u.tSuccess.Format("2006-01-02 15:04:05 MST") + "; Current & previous IPs: " - for i := range u.ips { - s += u.ips[i] - if i != len(u.ips)-1 { - s += "," - } - } - } - return s -} - -type updateType struct { // internal - settings updateSettings // fixed - status updateStatus // changes for each update - extras updateExtras // past information - m sync.Mutex -} - -func (u *updateType) String() string { - return u.settings.String() + ": " + u.status.String() + "; " + u.extras.String() -} diff --git a/updater/update.go b/updater/update.go deleted file mode 100644 index 8bc92c19e..000000000 --- a/updater/update.go +++ /dev/null @@ -1,380 +0,0 @@ -package main - -import ( - "encoding/json" - "encoding/xml" - "log" - "net/http" - "strings" - "time" - - 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"` -} - -// i is the index of the update to update -func (env *envType) update(i int) { - u := &env.updates[i] - u.m.Lock() - defer u.m.Unlock() - if u.status.code == UPDATING { - log.Println(u.String()) - return - } - u.status.code = UPDATING - defer func() { - if u.status.code == UPDATING { - u.status.code = FAIL - u.status.message = "Status not changed from UPDATING" - } - }() - u.status.time = time.Now() - - // Get the public IP address - var ip string - var err error - if u.settings.ipmethod == "provider" { - ip = "" - } else if u.settings.ipmethod == "duckduckgo" { - ip, err = getPublicIP(env.httpClient, "https://duckduckgo.com/?q=ip") - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - } else if u.settings.ipmethod == "opendns" { - ip, err = getPublicIP(env.httpClient, "https://diagnostic.opendns.com/myip") - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - } else { // fixed IP - ip = u.settings.ipmethod - } - if ip != "" && len(u.extras.ips) > 0 && ip == u.extras.ips[0] { // same IP - u.status.code = UPTODATE - u.status.message = "No IP change for " + time.Since(u.extras.tSuccess).Round(time.Second).String() - return - } - - // Update the record - if u.settings.provider == "namecheap" { - url := namecheapURL + "?host=" + strings.ToLower(u.settings.host) + - "&domain=" + strings.ToLower(u.settings.domain) + "&password=" + strings.ToLower(u.settings.password) - if ip != "provider" { - url += "&ip=" + ip - } - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err := doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { // TODO test / combine with below - u.status.code = FAIL - u.status.message = r.URL.String() + " responded with status " + status - log.Println(u.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 { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if parsedXML.Errors.Error != "" { - u.status.code = FAIL - u.status.message = parsedXML.Errors.Error - log.Println(u.String()) - return - } - if parsedXML.IP == "" { - u.status.code = FAIL - u.status.message = "No IP address was sent back from DDNS server" - log.Println(u.String()) - return - } - if regexIP(parsedXML.IP) == "" { - u.status.code = FAIL - u.status.message = "IP address " + parsedXML.IP + " is not valid" - log.Println(u.String()) - return - } - ip = parsedXML.IP - } else if u.settings.provider == "godaddy" { - url := godaddyURL + "/" + strings.ToLower(u.settings.domain) + "/records/A/" + strings.ToLower(u.settings.host) - r, err := buildHTTPPutJSONAuth( - url, - "sso-key "+u.settings.password, // password is key:secret here - []goDaddyPutBody{ - goDaddyPutBody{ - ip, - }, - }, - ) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err := doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { - u.status.code = FAIL - u.status.message = "HTTP " + status - var parsedJSON struct { - Message string `json:"message"` - } - err = json.Unmarshal(content, &parsedJSON) - if err != nil { - u.status.message = err.Error() - } else if parsedJSON.Message != "" { - u.status.message += " - " + parsedJSON.Message - } - log.Println(u.String()) - return - } - } else if u.settings.provider == "duckdns" { - url := duckdnsURL + "?domains=" + strings.ToLower(u.settings.domain) + - "&token=" + u.settings.password + "&verbose=true" - if ip != "provider" { - url += "&ip=" + ip - } - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err := doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { - u.status.code = FAIL - u.status.message = "HTTP " + status - log.Println(u.String()) - return - } - s := string(content) - if s[0:2] == "KO" { - u.status.code = FAIL - u.status.message = "Bad DuckDNS domain/token combination" - log.Println(u.String()) - return - } else if s[0:2] == "OK" { - ip = regexIP(s) - if ip == "" { - u.status.code = FAIL - u.status.message = "DuckDNS did not respond with an IP address" - log.Println(u.String()) - return - } - } else { - u.status.code = FAIL - u.status.message = "DuckDNS responded with '" + s + "'" - log.Println(u.String()) - return - } - } else if u.settings.provider == "dreamhost" { - url := dreamhostURL + "/?key=" + u.settings.password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-list_records" - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err := doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { - u.status.code = FAIL - u.status.message = "HTTP " + status - log.Println(u.String()) - return - } - var dhList dreamhostList - err = json.Unmarshal(content, &dhList) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } else if dhList.Result != "success" { - u.status.code = FAIL - u.status.message = dhList.Result - log.Println(u.String()) - return - } - var oldIP string - var found bool - for _, data := range dhList.Data { - if data.Type == "A" && data.Record == u.settings.buildDomainName() { - if data.Editable == "0" { - u.status.code = FAIL - u.status.message = "Record data is not editable" - log.Println(u.String()) - return - } - oldIP := data.Value - if oldIP == ip { - u.status.code = UPTODATE - u.status.message = "No IP change for " + time.Since(u.extras.tSuccess).Round(time.Second).String() - return - } - found = true - break - } - } - if found { - url = dreamhostURL + "?key=" + u.settings.password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-remove_record&record=" + strings.ToLower(u.settings.domain) + "&type=A&value=" + oldIP - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err = doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { - u.status.code = FAIL - u.status.message = "HTTP " + status - log.Println(u.String()) - return - } - var dhResponse dreamhostReponse - err = json.Unmarshal(content, &dhResponse) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } else if dhResponse.Result != "success" { // this should not happen - u.status.code = FAIL - u.status.message = dhResponse.Result + " - " + dhResponse.Data - log.Println(u.String()) - return - } - } - url = dreamhostURL + "?key=" + u.settings.password + "&unique_id=" + uuid.New().String() + "&format=json&cmd=dns-add_record&record=" + strings.ToLower(u.settings.domain) + "&type=A&value=" + ip - r, err = http.NewRequest(http.MethodGet, url, nil) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - status, content, err = doHTTPRequest(env.httpClient, r) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } - if status != "200" { - u.status.code = FAIL - u.status.message = "HTTP " + status - log.Println(u.String()) - return - } - var dhResponse dreamhostReponse - err = json.Unmarshal(content, &dhResponse) - if err != nil { - u.status.code = FAIL - u.status.message = err.Error() - log.Println(u.String()) - return - } else if dhResponse.Result != "success" { - u.status.code = FAIL - u.status.message = dhResponse.Result + " - " + dhResponse.Data - log.Println(u.String()) - return - } - } - if len(u.extras.ips) > 0 && ip == u.extras.ips[0] { // same IP - u.status.code = UPTODATE - u.status.message = "No IP change for " + time.Since(u.extras.tSuccess).Round(time.Second).String() - err = env.dbContainer.updateIPTime(u.settings.domain, u.settings.host, ip) - if err != nil { - u.status.code = FAIL - u.status.message = "Cannot update database: " + err.Error() - } - return - } - // new IP - u.status.code = SUCCESS - u.status.message = "" - u.extras.tSuccess = time.Now() - u.extras.ips = append([]string{ip}, u.extras.ips...) - err = env.dbContainer.storeNewIP(u.settings.domain, u.settings.host, ip) - if err != nil { - u.status.code = FAIL - u.status.message = "Cannot update database: " + err.Error() - } -}