github.com/equinox-io/equinox@v1.2.1-0.20200723040547-60ffe7f858fe/sdk.go (about) 1 package equinox 2 3 import ( 4 "bytes" 5 "crypto" 6 "crypto/sha256" 7 "crypto/x509" 8 "encoding/hex" 9 "encoding/json" 10 "encoding/pem" 11 "errors" 12 "fmt" 13 "io" 14 "io/ioutil" 15 "net/http" 16 "os" 17 "runtime" 18 "time" 19 20 "github.com/equinox-io/equinox/internal/go-update" 21 "github.com/equinox-io/equinox/internal/osext" 22 "github.com/equinox-io/equinox/proto" 23 ) 24 25 const protocolVersion = "1" 26 const defaultCheckURL = "https://update.equinox.io/check" 27 const userAgent = "EquinoxSDK/1.0" 28 29 var NotAvailableErr = errors.New("No update available") 30 31 type Options struct { 32 // Channel specifies the name of an Equinox release channel to check for 33 // a newer version of the application. 34 // 35 // If empty, defaults to 'stable'. 36 Channel string 37 38 // Version requests an update to a specific version of the application. 39 // If specified, `Channel` is ignored. 40 Version string 41 42 // TargetPath defines the path to the file to update. 43 // The emptry string means 'the executable file of the running program'. 44 TargetPath string 45 46 // Create TargetPath replacement with this file mode. If zero, defaults to 0755. 47 TargetMode os.FileMode 48 49 // Public key to use for signature verification. If nil, no signature 50 // verification is done. Use `SetPublicKeyPEM` to set this field with PEM data. 51 PublicKey crypto.PublicKey 52 53 // Target operating system of the update. Uses the same standard OS names used 54 // by Go build tags (windows, darwin, linux, etc). 55 // If empty, it will be populated by consulting runtime.GOOS 56 OS string 57 58 // Target architecture of the update. Uses the same standard Arch names used 59 // by Go build tags (amd64, 386, arm, etc). 60 // If empty, it will be populated by consulting runtime.GOARCH 61 Arch string 62 63 // Target ARM architecture, if a specific one if required. Uses the same names 64 // as the GOARM environment variable (5, 6, 7). 65 // 66 // GoARM is ignored if Arch != 'arm'. 67 // GoARM is ignored if it is the empty string. Omit it if you do not need 68 // to distinguish between ARM versions. 69 GoARM string 70 71 // The current application version. This is used for statistics and reporting only, 72 // it is optional. 73 CurrentVersion string 74 75 // CheckURL is the URL to request an update check from. You should only set 76 // this if you are running an on-prem Equinox server. 77 // If empty the default Equinox update service endpoint is used. 78 CheckURL string 79 80 // HTTPClient is used to make all HTTP requests necessary for the update check protocol. 81 // You may configure it to use custom timeouts, proxy servers or other behaviors. 82 HTTPClient *http.Client 83 } 84 85 // Response is returned by Check when an update is available. It may be 86 // passed to Apply to perform the update. 87 type Response struct { 88 // Version of the release that will be updated to if applied. 89 ReleaseVersion string 90 91 // Title of the the release 92 ReleaseTitle string 93 94 // Additional details about the release 95 ReleaseDescription string 96 97 // Creation date of the release 98 ReleaseDate time.Time 99 100 downloadURL string 101 checksum []byte 102 signature []byte 103 patch proto.PatchKind 104 opts Options 105 } 106 107 // SetPublicKeyPEM is a convenience method to set the PublicKey property 108 // used for checking a completed update's signature by parsing a 109 // Public Key formatted as PEM data. 110 func (o *Options) SetPublicKeyPEM(pembytes []byte) error { 111 block, _ := pem.Decode(pembytes) 112 if block == nil { 113 return errors.New("couldn't parse PEM data") 114 } 115 116 pub, err := x509.ParsePKIXPublicKey(block.Bytes) 117 if err != nil { 118 return err 119 } 120 o.PublicKey = pub 121 return nil 122 } 123 124 // Check communicates with an Equinox update service to determine if 125 // an update for the given application matching the specified options is 126 // available. The returned error is nil only if an update is available. 127 // 128 // The appID is issued to you when creating an application at https://equinox.io 129 // 130 // You can compare the returned error to NotAvailableErr to differentiate between 131 // a successful check that found no update from other errors like a failed 132 // network connection. 133 func Check(appID string, opts Options) (Response, error) { 134 var req, err = checkRequest(appID, &opts) 135 136 if err != nil { 137 return Response{}, err 138 } 139 140 return doCheckRequest(opts, req) 141 } 142 143 func checkRequest(appID string, opts *Options) (*http.Request, error) { 144 if opts.Channel == "" { 145 opts.Channel = "stable" 146 } 147 if opts.TargetPath == "" { 148 var err error 149 opts.TargetPath, err = osext.Executable() 150 if err != nil { 151 return nil, err 152 } 153 } 154 if opts.OS == "" { 155 opts.OS = runtime.GOOS 156 } 157 if opts.Arch == "" { 158 opts.Arch = runtime.GOARCH 159 } 160 if opts.CheckURL == "" { 161 opts.CheckURL = defaultCheckURL 162 } 163 if opts.HTTPClient == nil { 164 opts.HTTPClient = new(http.Client) 165 } 166 opts.HTTPClient.Transport = newUserAgentTransport(userAgent, opts.HTTPClient.Transport) 167 168 checksum := computeChecksum(opts.TargetPath) 169 170 payload, err := json.Marshal(proto.Request{ 171 AppID: appID, 172 Channel: opts.Channel, 173 OS: opts.OS, 174 Arch: opts.Arch, 175 GoARM: opts.GoARM, 176 TargetVersion: opts.Version, 177 CurrentVersion: opts.CurrentVersion, 178 CurrentSHA256: checksum, 179 }) 180 181 req, err := http.NewRequest("POST", opts.CheckURL, bytes.NewReader(payload)) 182 if err != nil { 183 return nil, err 184 } 185 186 req.Header.Set("Accept", fmt.Sprintf("application/json; q=1; version=%s; charset=utf-8", protocolVersion)) 187 req.Header.Set("Content-Type", "application/json; charset=utf-8") 188 req.Close = true 189 190 return req, err 191 } 192 193 func doCheckRequest(opts Options, req *http.Request) (r Response, err error) { 194 resp, err := opts.HTTPClient.Do(req) 195 if err != nil { 196 return r, err 197 } 198 defer resp.Body.Close() 199 200 if resp.StatusCode != 200 { 201 body, _ := ioutil.ReadAll(resp.Body) 202 return r, fmt.Errorf("Server responded with %s: %s", resp.Status, body) 203 } 204 205 var protoResp proto.Response 206 err = json.NewDecoder(resp.Body).Decode(&protoResp) 207 if err != nil { 208 return r, err 209 } 210 211 if !protoResp.Available { 212 return r, NotAvailableErr 213 } 214 215 r.ReleaseVersion = protoResp.Release.Version 216 r.ReleaseTitle = protoResp.Release.Title 217 r.ReleaseDescription = protoResp.Release.Description 218 r.ReleaseDate = protoResp.Release.CreateDate 219 r.downloadURL = protoResp.DownloadURL 220 r.patch = protoResp.Patch 221 r.opts = opts 222 r.checksum, err = hex.DecodeString(protoResp.Checksum) 223 if err != nil { 224 return r, err 225 } 226 r.signature, err = hex.DecodeString(protoResp.Signature) 227 if err != nil { 228 return r, err 229 } 230 231 return r, nil 232 } 233 234 func computeChecksum(path string) string { 235 f, err := os.Open(path) 236 if err != nil { 237 return "" 238 } 239 defer f.Close() 240 h := sha256.New() 241 _, err = io.Copy(h, f) 242 if err != nil { 243 return "" 244 } 245 return hex.EncodeToString(h.Sum(nil)) 246 } 247 248 // Apply performs an update of the current executable (or TargetFile, if it was 249 // set on the Options) with the update specified by Response. 250 // 251 // Error is nil if and only if the entire update completes successfully. 252 func (r Response) Apply() error { 253 var req, opts, err = r.applyRequest() 254 255 if err != nil { 256 return err 257 } 258 259 return r.applyUpdate(req, opts) 260 } 261 262 func (r Response) applyRequest() (*http.Request, update.Options, error) { 263 opts := update.Options{ 264 TargetPath: r.opts.TargetPath, 265 TargetMode: r.opts.TargetMode, 266 Checksum: r.checksum, 267 Signature: r.signature, 268 Verifier: update.NewECDSAVerifier(), 269 PublicKey: r.opts.PublicKey, 270 } 271 switch r.patch { 272 case proto.PatchBSDiff: 273 opts.Patcher = update.NewBSDiffPatcher() 274 } 275 276 if err := opts.CheckPermissions(); err != nil { 277 return nil, opts, err 278 } 279 280 req, err := http.NewRequest("GET", r.downloadURL, nil) 281 return req, opts, err 282 } 283 284 func (r Response) applyUpdate(req *http.Request, opts update.Options) error { 285 // fetch the update 286 req.Close = true 287 resp, err := r.opts.HTTPClient.Do(req) 288 if err != nil { 289 return err 290 } 291 292 defer resp.Body.Close() 293 294 // check that we got a patch 295 if resp.StatusCode >= 400 { 296 msg := "error downloading patch" 297 298 id := resp.Header.Get("Request-Id") 299 if id != "" { 300 msg += ", request " + id 301 } 302 303 blob, err := ioutil.ReadAll(resp.Body) 304 if err == nil { 305 msg += ": " + string(bytes.TrimSpace(blob)) 306 } 307 return fmt.Errorf(msg) 308 } 309 310 return update.Apply(resp.Body, opts) 311 } 312 313 type userAgentTransport struct { 314 userAgent string 315 http.RoundTripper 316 } 317 318 func newUserAgentTransport(userAgent string, rt http.RoundTripper) *userAgentTransport { 319 if rt == nil { 320 rt = http.DefaultTransport 321 } 322 return &userAgentTransport{userAgent, rt} 323 } 324 325 func (t *userAgentTransport) RoundTrip(r *http.Request) (*http.Response, error) { 326 if r.Header.Get("User-Agent") == "" { 327 r.Header.Set("User-Agent", t.userAgent) 328 } 329 return t.RoundTripper.RoundTrip(r) 330 }