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  }