├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── cmd └── go-selfupdate │ ├── main.go │ └── main_test.go ├── example ├── public │ └── index.html ├── run-example.sh └── src │ ├── example-server │ └── main.go │ └── hello-updater │ └── main.go ├── go.mod ├── go.sum └── selfupdate ├── hide_noop.go ├── hide_windows.go ├── requester.go ├── selfupdate.go └── selfupdate_test.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Use glob syntax. 2 | syntax: glob 3 | 4 | *.DS_Store 5 | *.swp 6 | *.swo 7 | *.pyc 8 | *.php~ 9 | *.orig 10 | *~ 11 | *.db 12 | *.log 13 | public/* 14 | go-selfupdate/go-selfupdate 15 | example/example-server 16 | example/hello-updater 17 | example/public/* 18 | example/deployment/* 19 | example/go-selfupdate 20 | *cktime 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mark Sanborn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-selfupdate 2 | 3 | [![GoDoc](https://godoc.org/github.com/sanbornm/go-selfupdate/selfupdate?status.svg)](https://godoc.org/github.com/sanbornm/go-selfupdate/selfupdate) 4 | ![CI/CD](https://github.com/sanbornm/go-selfupdate/actions/workflows/ci.yml/badge.svg) 5 | 6 | Enable your Golang applications to self update. Inspired by Chrome based on Heroku's [hk](https://github.com/heroku/hk). 7 | 8 | ## Features 9 | 10 | * Tested on Mac, Linux, Arm, and Windows 11 | * Creates binary diffs with [bsdiff](http://www.daemonology.net/bsdiff/) allowing small incremental updates 12 | * Falls back to full binary update if diff fails to match SHA 13 | 14 | ## QuickStart 15 | 16 | ### Install library and update/patch creation utility 17 | 18 | `go install github.com/sanbornm/go-selfupdate/cmd/go-selfupdate@latest` 19 | 20 | ### Enable your App to Self Update 21 | 22 | `go get -u github.com/sanbornm/go-selfupdate/...` 23 | 24 | var updater = &selfupdate.Updater{ 25 | CurrentVersion: version, // the current version of your app used to determine if an update is necessary 26 | // these endpoints can be the same if everything is hosted in the same place 27 | ApiURL: "http://updates.yourdomain.com/", // endpoint to get update manifest 28 | BinURL: "http://updates.yourdomain.com/", // endpoint to get full binaries 29 | DiffURL: "http://updates.yourdomain.com/", // endpoint to get binary diff/patches 30 | Dir: "update/", // directory relative to your app to store temporary state files related to go-selfupdate 31 | CmdName: "myapp", // your app's name (must correspond to app name hosting the updates) 32 | // app name allows you to serve updates for multiple apps on the same server/endpoint 33 | } 34 | 35 | // go look for an update when your app starts up 36 | go updater.BackgroundRun() 37 | // your app continues to run... 38 | 39 | ### Push Out and Update 40 | 41 | go-selfupdate path-to-your-app the-version 42 | go-selfupdate myapp 1.2 43 | 44 | By default this will create a folder in your project called *public*. You can then rsync or transfer this to your webserver or S3. To change the output directory use `-o` flag. 45 | 46 | If you are cross compiling you can specify a directory: 47 | 48 | go-selfupdate /tmp/mybinares/ 1.2 49 | 50 | The directory should contain files with the name, $GOOS-$ARCH. Example: 51 | 52 | windows-386 53 | darwin-amd64 54 | linux-arm 55 | 56 | If you are using [goxc](https://github.com/laher/goxc) you can output the files with this naming format by specifying this config: 57 | 58 | "OutPath": "{{.Dest}}{{.PS}}{{.Version}}{{.PS}}{{.Os}}-{{.Arch}}", 59 | 60 | ## Update Protocol 61 | 62 | Updates are fetched from an HTTP(s) server. AWS S3 or static hosting can be used. A JSON manifest file is pulled first which points to the wanted version (usually latest) and matching metadata. SHA256 hash is currently the only metadata but new fields may be added here like signatures. `go-selfupdate` isn't aware of any versioning schemes. It doesn't know major/minor versions. It just knows the target version by name and can apply diffs based on current version and version you wish to move to. For example 1.0 to 5.0 or 1.0 to 1.1. You don't even need to use point numbers. You can use hashes, dates, etc for versions. 63 | 64 | GET yourserver.com/appname/linux-amd64.json 65 | 66 | 200 ok 67 | { 68 | "Version": "2", 69 | "Sha256": "..." // base64 70 | } 71 | 72 | then 73 | 74 | GET patches.yourserver.com/appname/1.1/1.2/linux-amd64 75 | 76 | 200 ok 77 | [bsdiff data] 78 | 79 | or 80 | 81 | GET fullbins.yourserver.com/appname/1.0/linux-amd64.gz 82 | 83 | 200 ok 84 | [gzipped executable data] 85 | 86 | The only required files are `/-.json` and `//-.gz` everything else is optional. If you wanted to you could skip using go-selfupdate CLI tool and generate these two files manually or with another tool. 87 | 88 | ## Config 89 | 90 | Updater Config options: 91 | 92 | type Updater struct { 93 | CurrentVersion string // Currently running version. `dev` is a special version here and will cause the updater to never update. 94 | ApiURL string // Base URL for API requests (JSON files). 95 | CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary. 96 | BinURL string // Base URL for full binary downloads. 97 | DiffURL string // Base URL for diff downloads. 98 | Dir string // Directory to store selfupdate state. 99 | ForceCheck bool // Check for update regardless of cktime timestamp 100 | CheckTime int // Time in hours before next check 101 | RandomizeTime int // Time in hours to randomize with CheckTime 102 | Requester Requester // Optional parameter to override existing HTTP request handler 103 | Info struct { 104 | Version string 105 | Sha256 []byte 106 | } 107 | OnSuccessfulUpdate func() // Optional function to run after an update has successfully taken place 108 | } 109 | 110 | ### Restart on update 111 | 112 | It is common for an app to want to restart to apply the update. `go-selfupdate` gives you a hook to do that but leaves it up to you on how and when to restart as it differs for all apps. If you have a service restart application like Docker or systemd you can simply exit and let the upstream app start/restart your application. Just set the `OnSuccessfulUpdate` hook: 113 | 114 | u.OnSuccessfulUpdate = func() { os.Exit(0) } 115 | 116 | Or maybe you have a fancy graceful restart library/func: 117 | 118 | u.OnSuccessfulUpdate = func() { gracefullyRestartMyApp() } 119 | 120 | ## State 121 | 122 | go-selfupdate will keep a Go time.Time formatted timestamp in a file named `cktime` in folder specified by `Updater.Dir`. This can be useful for debugging to see when the next update can be applied or allow other applications to manipulate it. -------------------------------------------------------------------------------- /cmd/go-selfupdate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/sha256" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "os" 13 | "path/filepath" 14 | "runtime" 15 | 16 | "github.com/kr/binarydist" 17 | ) 18 | 19 | var version, genDir string 20 | 21 | type current struct { 22 | Version string 23 | Sha256 []byte 24 | } 25 | 26 | func generateSha256(path string) []byte { 27 | h := sha256.New() 28 | b, err := ioutil.ReadFile(path) 29 | if err != nil { 30 | fmt.Println(err) 31 | } 32 | h.Write(b) 33 | sum := h.Sum(nil) 34 | return sum 35 | //return base64.URLEncoding.EncodeToString(sum) 36 | } 37 | 38 | type gzReader struct { 39 | z, r io.ReadCloser 40 | } 41 | 42 | func (g *gzReader) Read(p []byte) (int, error) { 43 | return g.z.Read(p) 44 | } 45 | 46 | func (g *gzReader) Close() error { 47 | g.z.Close() 48 | return g.r.Close() 49 | } 50 | 51 | func newGzReader(r io.ReadCloser) io.ReadCloser { 52 | var err error 53 | g := new(gzReader) 54 | g.r = r 55 | g.z, err = gzip.NewReader(r) 56 | if err != nil { 57 | panic(err) 58 | } 59 | return g 60 | } 61 | 62 | func createUpdate(path string, platform string) { 63 | c := current{Version: version, Sha256: generateSha256(path)} 64 | 65 | b, err := json.MarshalIndent(c, "", " ") 66 | if err != nil { 67 | fmt.Println("error:", err) 68 | } 69 | err = ioutil.WriteFile(filepath.Join(genDir, platform+".json"), b, 0755) 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | os.MkdirAll(filepath.Join(genDir, version), 0755) 75 | 76 | var buf bytes.Buffer 77 | w := gzip.NewWriter(&buf) 78 | f, err := ioutil.ReadFile(path) 79 | if err != nil { 80 | panic(err) 81 | } 82 | w.Write(f) 83 | w.Close() // You must close this first to flush the bytes to the buffer. 84 | err = ioutil.WriteFile(filepath.Join(genDir, version, platform+".gz"), buf.Bytes(), 0755) 85 | 86 | files, err := ioutil.ReadDir(genDir) 87 | if err != nil { 88 | fmt.Println(err) 89 | } 90 | 91 | for _, file := range files { 92 | if file.IsDir() == false { 93 | continue 94 | } 95 | if file.Name() == version { 96 | continue 97 | } 98 | 99 | os.Mkdir(filepath.Join(genDir, file.Name(), version), 0755) 100 | 101 | fName := filepath.Join(genDir, file.Name(), platform+".gz") 102 | old, err := os.Open(fName) 103 | if err != nil { 104 | // Don't have an old release for this os/arch, continue on 105 | continue 106 | } 107 | 108 | fName = filepath.Join(genDir, version, platform+".gz") 109 | newF, err := os.Open(fName) 110 | if err != nil { 111 | fmt.Fprintf(os.Stderr, "Can't open %s: error: %s\n", fName, err) 112 | os.Exit(1) 113 | } 114 | 115 | ar := newGzReader(old) 116 | defer ar.Close() 117 | br := newGzReader(newF) 118 | defer br.Close() 119 | patch := new(bytes.Buffer) 120 | if err := binarydist.Diff(ar, br, patch); err != nil { 121 | panic(err) 122 | } 123 | ioutil.WriteFile(filepath.Join(genDir, file.Name(), version, platform), patch.Bytes(), 0755) 124 | } 125 | } 126 | 127 | func printUsage() { 128 | fmt.Println("") 129 | fmt.Println("Positional arguments:") 130 | fmt.Println("\tSingle platform: go-selfupdate myapp 1.2") 131 | fmt.Println("\tCross platform: go-selfupdate /tmp/mybinares/ 1.2") 132 | } 133 | 134 | func createBuildDir() { 135 | os.MkdirAll(genDir, 0755) 136 | } 137 | 138 | func main() { 139 | outputDirFlag := flag.String("o", "public", "Output directory for writing updates") 140 | 141 | var defaultPlatform string 142 | goos := os.Getenv("GOOS") 143 | goarch := os.Getenv("GOARCH") 144 | if goos != "" && goarch != "" { 145 | defaultPlatform = goos + "-" + goarch 146 | } else { 147 | defaultPlatform = runtime.GOOS + "-" + runtime.GOARCH 148 | } 149 | platformFlag := flag.String("platform", defaultPlatform, 150 | "Target platform in the form OS-ARCH. Defaults to running os/arch or the combination of the environment variables GOOS and GOARCH if both are set.") 151 | 152 | flag.Parse() 153 | if flag.NArg() < 2 { 154 | flag.Usage() 155 | printUsage() 156 | os.Exit(0) 157 | } 158 | 159 | platform := *platformFlag 160 | appPath := flag.Arg(0) 161 | version = flag.Arg(1) 162 | genDir = *outputDirFlag 163 | 164 | createBuildDir() 165 | 166 | // If dir is given create update for each file 167 | fi, err := os.Stat(appPath) 168 | if err != nil { 169 | panic(err) 170 | } 171 | 172 | if fi.IsDir() { 173 | files, err := ioutil.ReadDir(appPath) 174 | if err == nil { 175 | for _, file := range files { 176 | createUpdate(filepath.Join(appPath, file.Name()), file.Name()) 177 | } 178 | os.Exit(0) 179 | } 180 | } 181 | 182 | createUpdate(appPath, platform) 183 | } 184 | -------------------------------------------------------------------------------- /cmd/go-selfupdate/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestUpdater(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | Hello Updater Example Server 3 | I serve updates of the hello-updater program! 4 | -------------------------------------------------------------------------------- /example/run-example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "This example will compile the hello-updater application a few times with" 4 | echo "different version strings and demonstrate go-selfupdate's functionality." 5 | echo "If the version is 'dev', no update checking will be performed." 6 | echo 7 | 8 | # build latest/dev local version of go-selfupdate 9 | SELFUPDATE_PATH=../cmd/go-selfupdate/main.go 10 | if [ -f "$SELFUPDATE_PATH" ]; then 11 | go build -o go-selfupdate ../cmd/go-selfupdate 12 | fi 13 | 14 | rm -rf deployment/update deployment/hello* public/hello-updater 15 | 16 | echo "Building example-server" 17 | go build -o example-server src/example-server/main.go 18 | 19 | echo "Running example server" 20 | killall -q example-server 21 | ./example-server & 22 | 23 | read -n 1 -p "Press any key to start." ignored; echo 24 | 25 | echo "Building dev version of hello-updater"; echo 26 | go build -ldflags="-X main.version=dev" -o hello-updater src/hello-updater/main.go 27 | 28 | echo "Creating deployment folder and copying hello-updater to it"; echo 29 | mkdir -p deployment/ && cp hello-updater deployment/ 30 | 31 | 32 | echo "Running deployment/hello-updater" 33 | deployment/hello-updater 34 | read -n 1 -p "Press any key to continue." ignored; echo 35 | echo; echo "=========="; echo 36 | 37 | for (( minor=0; minor<=2; minor++ )); do 38 | echo "Building hello-updater with version set to 1.$minor" 39 | go build -ldflags="-X main.version=1.$minor" -o hello-updater src/hello-updater/main.go 40 | 41 | echo "Running ./go-selfupdate to make update available via example-server"; echo 42 | ./go-selfupdate -o public/hello-updater/ hello-updater 1.$minor 43 | 44 | if (( $minor == 0 )); then 45 | echo "Copying version 1.0 to deployment so it can self-update"; echo 46 | cp hello-updater deployment/ 47 | cp hello-updater deployment/hello-updater-1.0 48 | fi 49 | 50 | echo "Running deployment/hello-updater" 51 | deployment/hello-updater 52 | read -n 1 -p "Press any key to continue." ignored; echo 53 | echo; echo "=========="; echo 54 | done 55 | 56 | echo "Running deployment/hello-updater-1.0 backup copy" 57 | deployment/hello-updater-1.0 58 | read -n 1 -p "Press any key to continue." ignored; echo 59 | echo; echo "=========="; echo 60 | 61 | echo "Building unknown version of hello-updater"; echo 62 | go build -ldflags="-X main.version=unknown" -o hello-updater src/hello-updater/main.go 63 | echo "Copying unknown version to deployment so it can self-update"; echo 64 | cp hello-updater deployment/ 65 | 66 | echo "Running deployment/hello-updater" 67 | deployment/hello-updater 68 | sleep 5 69 | echo; echo "Re-running deployment/hello-updater" 70 | deployment/hello-updater 71 | sleep 5 72 | echo; echo 73 | 74 | echo "Shutting down example-server" 75 | killall example-server 76 | -------------------------------------------------------------------------------- /example/src/example-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | var servePath = flag.String("dir", "./public", "path to serve") 10 | 11 | type logHandler struct { 12 | handler http.Handler 13 | } 14 | 15 | func (lh *logHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 16 | log.Printf("(example-server) received request %s\n", r.URL.RequestURI()) 17 | lh.handler.ServeHTTP(rw, r) 18 | } 19 | 20 | func main() { 21 | log.SetFlags(log.LstdFlags | log.Lshortfile) 22 | flag.Parse() 23 | 24 | // Simple static webserver with logging: 25 | log.Printf("Starting HTTP server on :8080 serving path %q Ctrl + C to close and quit", *servePath) 26 | log.Fatal(http.ListenAndServe(":8080", &logHandler{ 27 | handler: http.FileServer(http.Dir(*servePath))}, 28 | )) 29 | } 30 | -------------------------------------------------------------------------------- /example/src/hello-updater/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/sanbornm/go-selfupdate/selfupdate" 7 | ) 8 | 9 | // The purpose of this app is to provide a simple example that just prints 10 | // its version and updates to the latest version from example-server 11 | // on localhost:8080. 12 | 13 | // the app's version. This will be set on build. 14 | var version string 15 | 16 | // go-selfupdate setup and config 17 | var updater = &selfupdate.Updater{ 18 | CurrentVersion: version, // Manually update the const, or set it using `go build -ldflags="-X main.VERSION=" -o hello-updater src/hello-updater/main.go` 19 | ApiURL: "http://localhost:8080/", // The server hosting `$CmdName/$GOOS-$ARCH.json` which contains the checksum for the binary 20 | BinURL: "http://localhost:8080/", // The server hosting the zip file containing the binary application which is a fallback for the patch method 21 | DiffURL: "http://localhost:8080/", // The server hosting the binary patch diff for incremental updates 22 | Dir: "update/", // The directory created by the app when run which stores the cktime file 23 | CmdName: "hello-updater", // The app name which is appended to the ApiURL to look for an update 24 | ForceCheck: true, // For this example, always check for an update unless the version is "dev" 25 | } 26 | 27 | func main() { 28 | log.SetFlags(log.LstdFlags | log.Lshortfile) 29 | 30 | // print the current version 31 | log.Printf("(hello-updater) Hello world! I am currently version: %q", updater.CurrentVersion) 32 | 33 | // try to update 34 | err := updater.BackgroundRun() 35 | if err != nil { 36 | log.Fatalln("Failed to update app:", err) 37 | } 38 | 39 | // print out latest version available 40 | log.Printf("(hello-updater) Latest version available: %q", updater.Info.Version) 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sanbornm/go-selfupdate 2 | 3 | go 1.15 4 | 5 | require github.com/kr/binarydist v0.1.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kr/binarydist v0.1.0 h1:6kAoLA9FMMnNGSehX0s1PdjbEaACznAv/W219j2uvyo= 2 | github.com/kr/binarydist v0.1.0/go.mod h1:DY7S//GCoz1BCd0B0EVrinCKAZN3pXe+MDaIZbXQVgM= 3 | -------------------------------------------------------------------------------- /selfupdate/hide_noop.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package selfupdate 5 | 6 | func hideFile(path string) error { 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /selfupdate/hide_windows.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "syscall" 5 | "unsafe" 6 | ) 7 | 8 | func hideFile(path string) error { 9 | kernel32 := syscall.NewLazyDLL("kernel32.dll") 10 | setFileAttributes := kernel32.NewProc("SetFileAttributesW") 11 | 12 | r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(path))), 2) 13 | 14 | if r1 == 0 { 15 | return err 16 | } else { 17 | return nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /selfupdate/requester.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // Requester interface allows developers to customize the method in which 10 | // requests are made to retrieve the version and binary. 11 | type Requester interface { 12 | Fetch(url string) (io.ReadCloser, error) 13 | } 14 | 15 | // HTTPRequester is the normal requester that is used and does an HTTP 16 | // to the URL location requested to retrieve the specified data. 17 | type HTTPRequester struct{} 18 | 19 | // Fetch will return an HTTP request to the specified url and return 20 | // the body of the result. An error will occur for a non 200 status code. 21 | func (httpRequester *HTTPRequester) Fetch(url string) (io.ReadCloser, error) { 22 | resp, err := http.Get(url) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | if resp.StatusCode != 200 { 28 | return nil, fmt.Errorf("bad http status from %s: %v", url, resp.Status) 29 | } 30 | 31 | return resp.Body, nil 32 | } 33 | 34 | // mockRequester used for some mock testing to ensure the requester contract 35 | // works as specified. 36 | type mockRequester struct { 37 | currentIndex int 38 | fetches []func(string) (io.ReadCloser, error) 39 | } 40 | 41 | func (mr *mockRequester) handleRequest(requestHandler func(string) (io.ReadCloser, error)) { 42 | if mr.fetches == nil { 43 | mr.fetches = []func(string) (io.ReadCloser, error){} 44 | } 45 | mr.fetches = append(mr.fetches, requestHandler) 46 | } 47 | 48 | func (mr *mockRequester) Fetch(url string) (io.ReadCloser, error) { 49 | if len(mr.fetches) <= mr.currentIndex { 50 | return nil, fmt.Errorf("no for currentIndex %d to mock", mr.currentIndex) 51 | } 52 | current := mr.fetches[mr.currentIndex] 53 | mr.currentIndex++ 54 | 55 | return current(url) 56 | } 57 | -------------------------------------------------------------------------------- /selfupdate/selfupdate.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/sha256" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "math/rand" 14 | "net/url" 15 | "os" 16 | "path/filepath" 17 | "runtime" 18 | "time" 19 | 20 | "github.com/kr/binarydist" 21 | ) 22 | 23 | const ( 24 | // holds a timestamp which triggers the next update 25 | upcktimePath = "cktime" // path to timestamp file relative to u.Dir 26 | plat = runtime.GOOS + "-" + runtime.GOARCH // ex: linux-amd64 27 | ) 28 | 29 | var ( 30 | ErrHashMismatch = errors.New("new file hash mismatch after patch") 31 | 32 | defaultHTTPRequester = HTTPRequester{} 33 | ) 34 | 35 | // Updater is the configuration and runtime data for doing an update. 36 | // 37 | // Note that ApiURL, BinURL and DiffURL should have the same value if all files are available at the same location. 38 | // 39 | // Example: 40 | // 41 | // updater := &selfupdate.Updater{ 42 | // CurrentVersion: version, 43 | // ApiURL: "http://updates.yourdomain.com/", 44 | // BinURL: "http://updates.yourdownmain.com/", 45 | // DiffURL: "http://updates.yourdomain.com/", 46 | // Dir: "update/", 47 | // CmdName: "myapp", // app name 48 | // } 49 | // if updater != nil { 50 | // go updater.BackgroundRun() 51 | // } 52 | type Updater struct { 53 | CurrentVersion string // Currently running version. `dev` is a special version here and will cause the updater to never update. 54 | ApiURL string // Base URL for API requests (JSON files). 55 | CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary. 56 | BinURL string // Base URL for full binary downloads. 57 | DiffURL string // Base URL for diff downloads. 58 | Dir string // Directory to store selfupdate state. 59 | ForceCheck bool // Check for update regardless of cktime timestamp 60 | CheckTime int // Time in hours before next check 61 | RandomizeTime int // Time in hours to randomize with CheckTime 62 | Requester Requester // Optional parameter to override existing HTTP request handler 63 | Info struct { 64 | Version string 65 | Sha256 []byte 66 | } 67 | OnSuccessfulUpdate func() // Optional function to run after an update has successfully taken place 68 | } 69 | 70 | func (u *Updater) getExecRelativeDir(dir string) string { 71 | filename, _ := os.Executable() 72 | path := filepath.Join(filepath.Dir(filename), dir) 73 | return path 74 | } 75 | 76 | func canUpdate() (err error) { 77 | // get the directory the file exists in 78 | path, err := os.Executable() 79 | if err != nil { 80 | return 81 | } 82 | 83 | fileDir := filepath.Dir(path) 84 | fileName := filepath.Base(path) 85 | 86 | // attempt to open a file in the file's directory 87 | newPath := filepath.Join(fileDir, fmt.Sprintf(".%s.new", fileName)) 88 | fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) 89 | if err != nil { 90 | return 91 | } 92 | fp.Close() 93 | 94 | _ = os.Remove(newPath) 95 | return 96 | } 97 | 98 | // BackgroundRun starts the update check and apply cycle. 99 | func (u *Updater) BackgroundRun() error { 100 | if err := os.MkdirAll(u.getExecRelativeDir(u.Dir), 0755); err != nil { 101 | // fail 102 | return err 103 | } 104 | // check to see if we want to check for updates based on version 105 | // and last update time 106 | if u.WantUpdate() { 107 | if err := canUpdate(); err != nil { 108 | // fail 109 | return err 110 | } 111 | 112 | u.SetUpdateTime() 113 | 114 | if err := u.Update(); err != nil { 115 | return err 116 | } 117 | } 118 | return nil 119 | } 120 | 121 | // WantUpdate returns boolean designating if an update is desired. If the app's version 122 | // is `dev` WantUpdate will return false. If u.ForceCheck is true or cktime is after now 123 | // WantUpdate will return true. 124 | func (u *Updater) WantUpdate() bool { 125 | if u.CurrentVersion == "dev" || (!u.ForceCheck && u.NextUpdate().After(time.Now())) { 126 | return false 127 | } 128 | 129 | return true 130 | } 131 | 132 | // NextUpdate returns the next time update should be checked 133 | func (u *Updater) NextUpdate() time.Time { 134 | path := u.getExecRelativeDir(u.Dir + upcktimePath) 135 | nextTime := readTime(path) 136 | 137 | return nextTime 138 | } 139 | 140 | // SetUpdateTime writes the next update time to the state file 141 | func (u *Updater) SetUpdateTime() bool { 142 | path := u.getExecRelativeDir(u.Dir + upcktimePath) 143 | wait := time.Duration(u.CheckTime) * time.Hour 144 | // Add 1 to random time since max is not included 145 | waitrand := time.Duration(rand.Intn(u.RandomizeTime+1)) * time.Hour 146 | 147 | return writeTime(path, time.Now().Add(wait+waitrand)) 148 | } 149 | 150 | // ClearUpdateState writes current time to state file 151 | func (u *Updater) ClearUpdateState() { 152 | path := u.getExecRelativeDir(u.Dir + upcktimePath) 153 | os.Remove(path) 154 | } 155 | 156 | // UpdateAvailable checks if update is available and returns version 157 | func (u *Updater) UpdateAvailable() (string, error) { 158 | path, err := os.Executable() 159 | if err != nil { 160 | return "", err 161 | } 162 | old, err := os.Open(path) 163 | if err != nil { 164 | return "", err 165 | } 166 | defer old.Close() 167 | 168 | err = u.fetchInfo() 169 | if err != nil { 170 | return "", err 171 | } 172 | if u.Info.Version == u.CurrentVersion { 173 | return "", nil 174 | } else { 175 | return u.Info.Version, nil 176 | } 177 | } 178 | 179 | // Update initiates the self update process 180 | func (u *Updater) Update() error { 181 | path, err := os.Executable() 182 | if err != nil { 183 | return err 184 | } 185 | 186 | if resolvedPath, err := filepath.EvalSymlinks(path); err == nil { 187 | path = resolvedPath 188 | } 189 | 190 | // go fetch latest updates manifest 191 | err = u.fetchInfo() 192 | if err != nil { 193 | return err 194 | } 195 | 196 | // we are on the latest version, nothing to do 197 | if u.Info.Version == u.CurrentVersion { 198 | return nil 199 | } 200 | 201 | old, err := os.Open(path) 202 | if err != nil { 203 | return err 204 | } 205 | defer old.Close() 206 | 207 | bin, err := u.fetchAndVerifyPatch(old) 208 | if err != nil { 209 | if err == ErrHashMismatch { 210 | log.Println("update: hash mismatch from patched binary") 211 | } else { 212 | if u.DiffURL != "" { 213 | log.Println("update: patching binary,", err) 214 | } 215 | } 216 | 217 | // if patch failed grab the full new bin 218 | bin, err = u.fetchAndVerifyFullBin() 219 | if err != nil { 220 | if err == ErrHashMismatch { 221 | log.Println("update: hash mismatch from full binary") 222 | } else { 223 | log.Println("update: fetching full binary,", err) 224 | } 225 | return err 226 | } 227 | } 228 | 229 | // close the old binary before installing because on windows 230 | // it can't be renamed if a handle to the file is still open 231 | old.Close() 232 | 233 | err, errRecover := fromStream(bytes.NewBuffer(bin)) 234 | if errRecover != nil { 235 | return fmt.Errorf("update and recovery errors: %q %q", err, errRecover) 236 | } 237 | if err != nil { 238 | return err 239 | } 240 | 241 | // update was successful, run func if set 242 | if u.OnSuccessfulUpdate != nil { 243 | u.OnSuccessfulUpdate() 244 | } 245 | 246 | return nil 247 | } 248 | 249 | func fromStream(updateWith io.Reader) (err error, errRecover error) { 250 | updatePath, err := os.Executable() 251 | if err != nil { 252 | return 253 | } 254 | 255 | var newBytes []byte 256 | newBytes, err = ioutil.ReadAll(updateWith) 257 | if err != nil { 258 | return 259 | } 260 | 261 | // get the directory the executable exists in 262 | updateDir := filepath.Dir(updatePath) 263 | filename := filepath.Base(updatePath) 264 | 265 | // Copy the contents of of newbinary to a the new executable file 266 | newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) 267 | fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) 268 | if err != nil { 269 | return 270 | } 271 | defer fp.Close() 272 | _, err = io.Copy(fp, bytes.NewReader(newBytes)) 273 | 274 | // if we don't call fp.Close(), windows won't let us move the new executable 275 | // because the file will still be "in use" 276 | fp.Close() 277 | 278 | // this is where we'll move the executable to so that we can swap in the updated replacement 279 | oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) 280 | 281 | // delete any existing old exec file - this is necessary on Windows for two reasons: 282 | // 1. after a successful update, Windows can't remove the .old file because the process is still running 283 | // 2. windows rename operations fail if the destination file already exists 284 | _ = os.Remove(oldPath) 285 | 286 | // move the existing executable to a new file in the same directory 287 | err = os.Rename(updatePath, oldPath) 288 | if err != nil { 289 | return 290 | } 291 | 292 | // move the new exectuable in to become the new program 293 | err = os.Rename(newPath, updatePath) 294 | 295 | if err != nil { 296 | // copy unsuccessful 297 | errRecover = os.Rename(oldPath, updatePath) 298 | } else { 299 | // copy successful, remove the old binary 300 | errRemove := os.Remove(oldPath) 301 | 302 | // windows has trouble with removing old binaries, so hide it instead 303 | if errRemove != nil { 304 | _ = hideFile(oldPath) 305 | } 306 | } 307 | 308 | return 309 | } 310 | 311 | // fetchInfo fetches the update JSON manifest at u.ApiURL/appname/platform.json 312 | // and updates u.Info. 313 | func (u *Updater) fetchInfo() error { 314 | r, err := u.fetch(u.ApiURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(plat) + ".json") 315 | if err != nil { 316 | return err 317 | } 318 | defer r.Close() 319 | err = json.NewDecoder(r).Decode(&u.Info) 320 | if err != nil { 321 | return err 322 | } 323 | if len(u.Info.Sha256) != sha256.Size { 324 | return errors.New("bad cmd hash in info") 325 | } 326 | return nil 327 | } 328 | 329 | func (u *Updater) fetchAndVerifyPatch(old io.Reader) ([]byte, error) { 330 | bin, err := u.fetchAndApplyPatch(old) 331 | if err != nil { 332 | return nil, err 333 | } 334 | if !verifySha(bin, u.Info.Sha256) { 335 | return nil, ErrHashMismatch 336 | } 337 | return bin, nil 338 | } 339 | 340 | func (u *Updater) fetchAndApplyPatch(old io.Reader) ([]byte, error) { 341 | r, err := u.fetch(u.DiffURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(u.CurrentVersion) + "/" + url.QueryEscape(u.Info.Version) + "/" + url.QueryEscape(plat)) 342 | if err != nil { 343 | return nil, err 344 | } 345 | defer r.Close() 346 | var buf bytes.Buffer 347 | err = binarydist.Patch(old, &buf, r) 348 | return buf.Bytes(), err 349 | } 350 | 351 | func (u *Updater) fetchAndVerifyFullBin() ([]byte, error) { 352 | bin, err := u.fetchBin() 353 | if err != nil { 354 | return nil, err 355 | } 356 | verified := verifySha(bin, u.Info.Sha256) 357 | if !verified { 358 | return nil, ErrHashMismatch 359 | } 360 | return bin, nil 361 | } 362 | 363 | func (u *Updater) fetchBin() ([]byte, error) { 364 | r, err := u.fetch(u.BinURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(u.Info.Version) + "/" + url.QueryEscape(plat) + ".gz") 365 | if err != nil { 366 | return nil, err 367 | } 368 | defer r.Close() 369 | buf := new(bytes.Buffer) 370 | gz, err := gzip.NewReader(r) 371 | if err != nil { 372 | return nil, err 373 | } 374 | if _, err = io.Copy(buf, gz); err != nil { 375 | return nil, err 376 | } 377 | 378 | return buf.Bytes(), nil 379 | } 380 | 381 | func (u *Updater) fetch(url string) (io.ReadCloser, error) { 382 | if u.Requester == nil { 383 | return defaultHTTPRequester.Fetch(url) 384 | } 385 | 386 | readCloser, err := u.Requester.Fetch(url) 387 | if err != nil { 388 | return nil, err 389 | } 390 | 391 | if readCloser == nil { 392 | return nil, fmt.Errorf("Fetch was expected to return non-nil ReadCloser") 393 | } 394 | 395 | return readCloser, nil 396 | } 397 | 398 | func readTime(path string) time.Time { 399 | p, err := ioutil.ReadFile(path) 400 | if os.IsNotExist(err) { 401 | return time.Time{} 402 | } 403 | if err != nil { 404 | return time.Now().Add(1000 * time.Hour) 405 | } 406 | t, err := time.Parse(time.RFC3339, string(p)) 407 | if err != nil { 408 | return time.Now().Add(1000 * time.Hour) 409 | } 410 | return t 411 | } 412 | 413 | func verifySha(bin []byte, sha []byte) bool { 414 | h := sha256.New() 415 | h.Write(bin) 416 | return bytes.Equal(h.Sum(nil), sha) 417 | } 418 | 419 | func writeTime(path string, t time.Time) bool { 420 | return ioutil.WriteFile(path, []byte(t.Format(time.RFC3339)), 0644) == nil 421 | } 422 | -------------------------------------------------------------------------------- /selfupdate/selfupdate_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestUpdaterFetchMustReturnNonNilReaderCloser(t *testing.T) { 11 | mr := &mockRequester{} 12 | mr.handleRequest( 13 | func(url string) (io.ReadCloser, error) { 14 | return nil, nil 15 | }) 16 | updater := createUpdater(mr) 17 | updater.CheckTime = 24 18 | updater.RandomizeTime = 24 19 | 20 | err := updater.BackgroundRun() 21 | 22 | if err != nil { 23 | equals(t, "Fetch was expected to return non-nil ReadCloser", err.Error()) 24 | } else { 25 | t.Log("Expected an error") 26 | t.Fail() 27 | } 28 | } 29 | 30 | func TestUpdaterWithEmptyPayloadNoErrorNoUpdate(t *testing.T) { 31 | mr := &mockRequester{} 32 | mr.handleRequest( 33 | func(url string) (io.ReadCloser, error) { 34 | equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) 35 | return newTestReaderCloser("{}"), nil 36 | }) 37 | updater := createUpdater(mr) 38 | updater.CheckTime = 24 39 | updater.RandomizeTime = 24 40 | 41 | err := updater.BackgroundRun() 42 | if err != nil { 43 | t.Errorf("Error occurred: %#v", err) 44 | } 45 | } 46 | 47 | func TestUpdaterCheckTime(t *testing.T) { 48 | mr := &mockRequester{} 49 | mr.handleRequest( 50 | func(url string) (io.ReadCloser, error) { 51 | equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) 52 | return newTestReaderCloser("{}"), nil 53 | }) 54 | 55 | // Run test with various time 56 | runTestTimeChecks(t, mr, 0, 0, false) 57 | runTestTimeChecks(t, mr, 0, 5, true) 58 | runTestTimeChecks(t, mr, 1, 0, true) 59 | runTestTimeChecks(t, mr, 100, 100, true) 60 | } 61 | 62 | // Helper function to run check time tests 63 | func runTestTimeChecks(t *testing.T, mr *mockRequester, checkTime int, randomizeTime int, expectUpdate bool) { 64 | updater := createUpdater(mr) 65 | updater.ClearUpdateState() 66 | updater.CheckTime = checkTime 67 | updater.RandomizeTime = randomizeTime 68 | 69 | updater.BackgroundRun() 70 | 71 | if updater.WantUpdate() == expectUpdate { 72 | t.Errorf("WantUpdate returned %v; want %v", updater.WantUpdate(), expectUpdate) 73 | } 74 | 75 | maxHrs := time.Duration(updater.CheckTime+updater.RandomizeTime) * time.Hour 76 | maxTime := time.Now().Add(maxHrs) 77 | 78 | if !updater.NextUpdate().Before(maxTime) { 79 | t.Errorf("NextUpdate should less than %s hrs (CheckTime + RandomizeTime) from now; now %s; next update %s", maxHrs, time.Now(), updater.NextUpdate()) 80 | } 81 | 82 | if maxHrs > 0 && !updater.NextUpdate().After(time.Now()) { 83 | t.Errorf("NextUpdate should be after now") 84 | } 85 | } 86 | 87 | func TestUpdaterWithEmptyPayloadNoErrorNoUpdateEscapedPath(t *testing.T) { 88 | mr := &mockRequester{} 89 | mr.handleRequest( 90 | func(url string) (io.ReadCloser, error) { 91 | equals(t, "http://updates.yourdomain.com/myapp%2Bfoo/darwin-amd64.json", url) 92 | return newTestReaderCloser("{}"), nil 93 | }) 94 | updater := createUpdaterWithEscapedCharacters(mr) 95 | 96 | err := updater.BackgroundRun() 97 | if err != nil { 98 | t.Errorf("Error occurred: %#v", err) 99 | } 100 | } 101 | 102 | func TestUpdateAvailable(t *testing.T) { 103 | mr := &mockRequester{} 104 | mr.handleRequest( 105 | func(url string) (io.ReadCloser, error) { 106 | equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) 107 | return newTestReaderCloser(`{ 108 | "Version": "2023-07-09-66c6c12", 109 | "Sha256": "Q2vvTOW0p69A37StVANN+/ko1ZQDTElomq7fVcex/02=" 110 | }`), nil 111 | }) 112 | updater := createUpdater(mr) 113 | 114 | version, err := updater.UpdateAvailable() 115 | if err != nil { 116 | t.Errorf("Error occurred: %#v", err) 117 | } 118 | equals(t, "2023-07-09-66c6c12", version) 119 | } 120 | 121 | func createUpdater(mr *mockRequester) *Updater { 122 | return &Updater{ 123 | CurrentVersion: "1.2", 124 | ApiURL: "http://updates.yourdomain.com/", 125 | BinURL: "http://updates.yourdownmain.com/", 126 | DiffURL: "http://updates.yourdomain.com/", 127 | Dir: "update/", 128 | CmdName: "myapp", // app name 129 | Requester: mr, 130 | } 131 | } 132 | 133 | func createUpdaterWithEscapedCharacters(mr *mockRequester) *Updater { 134 | return &Updater{ 135 | CurrentVersion: "1.2+foobar", 136 | ApiURL: "http://updates.yourdomain.com/", 137 | BinURL: "http://updates.yourdownmain.com/", 138 | DiffURL: "http://updates.yourdomain.com/", 139 | Dir: "update/", 140 | CmdName: "myapp+foo", // app name 141 | Requester: mr, 142 | } 143 | } 144 | 145 | func equals(t *testing.T, expected, actual interface{}) { 146 | if expected != actual { 147 | t.Logf("Expected: %#v got %#v\n", expected, actual) 148 | t.Fail() 149 | } 150 | } 151 | 152 | type testReadCloser struct { 153 | buffer *bytes.Buffer 154 | } 155 | 156 | func newTestReaderCloser(payload string) io.ReadCloser { 157 | return &testReadCloser{buffer: bytes.NewBufferString(payload)} 158 | } 159 | 160 | func (trc *testReadCloser) Read(p []byte) (n int, err error) { 161 | return trc.buffer.Read(p) 162 | } 163 | 164 | func (trc *testReadCloser) Close() error { 165 | return nil 166 | } 167 | --------------------------------------------------------------------------------