-
Notifications
You must be signed in to change notification settings - Fork 0
/
selfupdate.go
383 lines (328 loc) · 9.47 KB
/
selfupdate.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
package selfupdate
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/url"
"os"
"path/filepath"
"runtime"
"time"
)
const (
// holds a timestamp which triggers the next update
upcktimePath = "cktime" // path to timestamp file relative to u.Dir
plat = runtime.GOOS + "-" + runtime.GOARCH // ex: linux-amd64
)
var (
ErrHashMismatch = errors.New("new file hash mismatch after patch")
defaultHTTPRequester = HTTPRequester{}
)
// Updater is the configuration and runtime data for doing an update.
//
// Note that ApiURL, BinURL and DiffURL should have the same value if all files are available at the same location.
//
// Example:
//
// updater := &selfupdate.Updater{
// CurrentVersion: version,
// ApiURL: "http://updates.yourdomain.com/",
// BinURL: "http://updates.yourdownmain.com/",
// DiffURL: "http://updates.yourdomain.com/",
// Dir: "update/",
// CmdName: "myapp", // app name
// }
// if updater != nil {
// go updater.Run()
// }
type Updater struct {
CurrentVersion string // Currently running version. `dev` is a special version here and will cause the updater to never update.
ApiURL string // Base URL for API requests (JSON files).
CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary.
BinURL string // Base URL for full binary downloads.
Dir string // Directory to store selfupdate state.
ForceCheck bool // Check for update regardless of cktime timestamp
CheckTime int // Time in hours before next check
RandomizeTime int // Time in hours to randomize with CheckTime
Requester Requester // Optional parameter to override existing HTTP request handler
Info struct {
Version string
Sha256 []byte
}
OnSuccessfulUpdate func() // Optional function to run after an update has successfully taken place
}
func (u *Updater) getExecRelativeDir(dir string) string {
filename, _ := os.Executable()
path := filepath.Join(filepath.Dir(filename), dir)
return path
}
func canUpdate() (err error) {
// get the directory the file exists in
path, err := os.Executable()
if err != nil {
return
}
fileDir := filepath.Dir(path)
fileName := filepath.Base(path)
// attempt to open a file in the file's directory
newPath := filepath.Join(fileDir, fmt.Sprintf(".%s.new", fileName))
fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return
}
fp.Close()
_ = os.Remove(newPath)
return
}
// BackgroundRun starts the update check and apply cycle.
func (u *Updater) Run() error {
if err := os.MkdirAll(u.getExecRelativeDir(u.Dir), 0755); err != nil {
// fail
return err
}
// check to see if we want to check for updates based on version
// and last update time
if u.WantUpdate() {
if err := canUpdate(); err != nil {
// fail
return err
}
u.SetUpdateTime()
if err := u.Update(); err != nil {
return err
}
}
return nil
}
// WantUpdate returns boolean designating if an update is desired. If the app's version
// is `dev` WantUpdate will return false. If u.ForceCheck is true or cktime is after now
// WantUpdate will return true.
func (u *Updater) WantUpdate() bool {
if u.CurrentVersion == "dev" || (!u.ForceCheck && u.NextUpdate().After(time.Now())) {
return false
}
return true
}
// NextUpdate returns the next time update should be checked
func (u *Updater) NextUpdate() time.Time {
path := u.getExecRelativeDir(u.Dir + upcktimePath)
nextTime := readTime(path)
return nextTime
}
// SetUpdateTime writes the next update time to the state file
func (u *Updater) SetUpdateTime() bool {
path := u.getExecRelativeDir(u.Dir + upcktimePath)
wait := time.Duration(u.CheckTime) * time.Hour
// Add 1 to random time since max is not included
waitrand := time.Duration(rand.Intn(u.RandomizeTime+1)) * time.Hour
return writeTime(path, time.Now().Add(wait+waitrand))
}
// ClearUpdateState writes current time to state file
func (u *Updater) ClearUpdateState() {
path := u.getExecRelativeDir(u.Dir + upcktimePath)
os.Remove(path)
}
// UpdateAvailable checks if update is available and returns version
func (u *Updater) UpdateAvailable() (string, error) {
path, err := os.Executable()
if err != nil {
return "", err
}
old, err := os.Open(path)
if err != nil {
return "", err
}
defer old.Close()
err = u.fetchInfo()
if err != nil {
return "", err
}
if u.Info.Version == u.CurrentVersion {
return "", nil
} else {
return u.Info.Version, nil
}
}
// Update initiates the self update process
func (u *Updater) Update() error {
path, err := os.Executable()
if err != nil {
return err
}
if resolvedPath, err := filepath.EvalSymlinks(path); err == nil {
path = resolvedPath
}
// go fetch latest updates manifest
err = u.fetchInfo()
if err != nil {
return err
}
// we are on the latest version, nothing to do
if u.Info.Version == u.CurrentVersion {
return nil
}
old, err := os.Open(path)
if err != nil {
return err
}
defer old.Close()
bin, err := u.fetchAndVerifyFullBin()
if err != nil {
if err == ErrHashMismatch {
log.Println("update: hash mismatch from full binary")
} else {
log.Println("update: fetching full binary,", err)
}
return err
}
// close the old binary before installing because on windows
// it can't be renamed if a handle to the file is still open
old.Close()
err, errRecover := fromStream(bytes.NewBuffer(bin))
if errRecover != nil {
return fmt.Errorf("update and recovery errors: %q %q", err, errRecover)
}
if err != nil {
return err
}
// update was successful, run func if set
if u.OnSuccessfulUpdate != nil {
u.OnSuccessfulUpdate()
}
return nil
}
func fromStream(updateWith io.Reader) (err error, errRecover error) {
updatePath, err := os.Executable()
if err != nil {
return
}
var newBytes []byte
newBytes, err = io.ReadAll(updateWith)
if err != nil {
return
}
// get the directory the executable exists in
updateDir := filepath.Dir(updatePath)
filename := filepath.Base(updatePath)
// Copy the contents of of newbinary to a the new executable file
newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename))
fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return
}
defer fp.Close()
_, err = io.Copy(fp, bytes.NewReader(newBytes))
// if we don't call fp.Close(), windows won't let us move the new executable
// because the file will still be "in use"
fp.Close()
// this is where we'll move the executable to so that we can swap in the updated replacement
oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename))
// delete any existing old exec file - this is necessary on Windows for two reasons:
// 1. after a successful update, Windows can't remove the .old file because the process is still running
// 2. windows rename operations fail if the destination file already exists
_ = os.Remove(oldPath)
// move the existing executable to a new file in the same directory
err = os.Rename(updatePath, oldPath)
if err != nil {
return
}
// move the new exectuable in to become the new program
err = os.Rename(newPath, updatePath)
if err != nil {
// copy unsuccessful
errRecover = os.Rename(oldPath, updatePath)
} else {
// copy successful, remove the old binary
errRemove := os.Remove(oldPath)
// windows has trouble with removing old binaries, so hide it instead
if errRemove != nil {
_ = hideFile(oldPath)
}
}
return
}
// fetchInfo fetches the update JSON manifest at u.ApiURL/appname/platform.json
// and updates u.Info.
func (u *Updater) fetchInfo() error {
r, err := u.fetch(u.ApiURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(plat) + ".json")
if err != nil {
return err
}
defer r.Close()
err = json.NewDecoder(r).Decode(&u.Info)
if err != nil {
return err
}
if len(u.Info.Sha256) != sha256.Size {
return errors.New("bad cmd hash in info")
}
return nil
}
func (u *Updater) fetchAndVerifyFullBin() ([]byte, error) {
bin, err := u.fetchBin()
if err != nil {
return nil, err
}
verified := verifySha(bin, u.Info.Sha256)
if !verified {
return nil, ErrHashMismatch
}
return bin, nil
}
func (u *Updater) fetchBin() ([]byte, error) {
r, err := u.fetch(u.BinURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(u.Info.Version) + "/" + url.QueryEscape(plat) + ".gz")
if err != nil {
return nil, err
}
defer r.Close()
buf := new(bytes.Buffer)
gz, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
if _, err = io.Copy(buf, gz); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (u *Updater) fetch(url string) (io.ReadCloser, error) {
if u.Requester == nil {
return defaultHTTPRequester.Fetch(url)
}
readCloser, err := u.Requester.Fetch(url)
if err != nil {
return nil, err
}
if readCloser == nil {
return nil, fmt.Errorf("Fetch was expected to return non-nil ReadCloser")
}
return readCloser, nil
}
func readTime(path string) time.Time {
p, err := os.ReadFile(path)
if os.IsNotExist(err) {
return time.Time{}
}
if err != nil {
return time.Now().Add(1000 * time.Hour)
}
t, err := time.Parse(time.RFC3339, string(p))
if err != nil {
return time.Now().Add(1000 * time.Hour)
}
return t
}
func verifySha(bin []byte, sha []byte) bool {
h := sha256.New()
h.Write(bin)
return bytes.Equal(h.Sum(nil), sha)
}
func writeTime(path string, t time.Time) bool {
return os.WriteFile(path, []byte(t.Format(time.RFC3339)), 0644) == nil
}