consul/vendor/github.com/hashicorp/go-checkpoint/checkpoint.go

477 lines
12 KiB
Go
Raw Normal View History

// checkpoint is a package for checking version information and alerts
// for a HashiCorp product.
package checkpoint
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"io/ioutil"
mrand "math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"time"
"github.com/hashicorp/go-cleanhttp"
uuid "github.com/hashicorp/go-uuid"
)
var magicBytes [4]byte = [4]byte{0x35, 0x77, 0x69, 0xFB}
// ReportParams are the parameters for configuring a telemetry report.
type ReportParams struct {
// Signature is some random signature that should be stored and used
// as a cookie-like value. This ensures that alerts aren't repeated.
// If the signature is changed, repeat alerts may be sent down. The
// signature should NOT be anything identifiable to a user (such as
// a MAC address). It should be random.
//
// If SignatureFile is given, then the signature will be read from this
// file. If the file doesn't exist, then a random signature will
// automatically be generated and stored here. SignatureFile will be
// ignored if Signature is given.
Signature string `json:"signature"`
SignatureFile string `json:"-"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Arch string `json:"arch"`
OS string `json:"os"`
Payload interface{} `json:"payload,omitempty"`
Product string `json:"product"`
RunID string `json:"run_id"`
SchemaVersion string `json:"schema_version"`
Version string `json:"version"`
}
func (i *ReportParams) signature() string {
signature := i.Signature
if i.Signature == "" && i.SignatureFile != "" {
var err error
signature, err = checkSignature(i.SignatureFile)
if err != nil {
return ""
}
}
return signature
}
// Report sends telemetry information to checkpoint
func Report(ctx context.Context, r *ReportParams) error {
if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" {
return nil
}
req, err := ReportRequest(r)
if err != nil {
return err
}
client := cleanhttp.DefaultClient()
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return err
}
if resp.StatusCode != 201 {
return fmt.Errorf("Unknown status: %d", resp.StatusCode)
}
return nil
}
// ReportRequest creates a request object for making a report
func ReportRequest(r *ReportParams) (*http.Request, error) {
// Populate some fields automatically if we can
if r.RunID == "" {
uuid, err := uuid.GenerateUUID()
if err != nil {
return nil, err
}
r.RunID = uuid
}
if r.Arch == "" {
r.Arch = runtime.GOARCH
}
if r.OS == "" {
r.OS = runtime.GOOS
}
if r.Signature == "" {
r.Signature = r.signature()
}
b, err := json.Marshal(r)
if err != nil {
return nil, err
}
u := &url.URL{
Scheme: "https",
Host: "checkpoint-api.hashicorp.com",
Path: fmt.Sprintf("/v1/telemetry/%s", r.Product),
}
req, err := http.NewRequest("POST", u.String(), bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "HashiCorp/go-checkpoint")
return req, nil
}
// CheckParams are the parameters for configuring a check request.
type CheckParams struct {
// Product and version are used to lookup the correct product and
// alerts for the proper version. The version is also used to perform
// a version check.
Product string
Version string
// Arch and OS are used to filter alerts potentially only to things
// affecting a specific os/arch combination. If these aren't specified,
// they'll be automatically filled in.
Arch string
OS string
// Signature is some random signature that should be stored and used
// as a cookie-like value. This ensures that alerts aren't repeated.
// If the signature is changed, repeat alerts may be sent down. The
// signature should NOT be anything identifiable to a user (such as
// a MAC address). It should be random.
//
// If SignatureFile is given, then the signature will be read from this
// file. If the file doesn't exist, then a random signature will
// automatically be generated and stored here. SignatureFile will be
// ignored if Signature is given.
Signature string
SignatureFile string
// CacheFile, if specified, will cache the result of a check. The
// duration of the cache is specified by CacheDuration, and defaults
// to 48 hours if not specified. If the CacheFile is newer than the
// CacheDuration, than the Check will short-circuit and use those
// results.
//
// If the CacheFile directory doesn't exist, it will be created with
// permissions 0755.
CacheFile string
CacheDuration time.Duration
// Force, if true, will force the check even if CHECKPOINT_DISABLE
// is set. Within HashiCorp products, this is ONLY USED when the user
// specifically requests it. This is never automatically done without
// the user's consent.
Force bool
}
// CheckResponse is the response for a check request.
type CheckResponse struct {
Product string
CurrentVersion string `json:"current_version"`
CurrentReleaseDate int `json:"current_release_date"`
CurrentDownloadURL string `json:"current_download_url"`
CurrentChangelogURL string `json:"current_changelog_url"`
ProjectWebsite string `json:"project_website"`
Outdated bool `json:"outdated"`
Alerts []*CheckAlert
}
// CheckAlert is a single alert message from a check request.
//
// These never have to be manually constructed, and are typically populated
// into a CheckResponse as a result of the Check request.
type CheckAlert struct {
ID int
Date int
Message string
URL string
Level string
}
// Check checks for alerts and new version information.
func Check(p *CheckParams) (*CheckResponse, error) {
if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" && !p.Force {
return &CheckResponse{}, nil
}
// set a default timeout of 3 sec for the check request (in milliseconds)
timeout := 3000
if _, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil {
timeout, _ = strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT"))
}
// If we have a cached result, then use that
if r, err := checkCache(p.Version, p.CacheFile, p.CacheDuration); err != nil {
return nil, err
} else if r != nil {
defer r.Close()
return checkResult(r)
}
var u url.URL
if p.Arch == "" {
p.Arch = runtime.GOARCH
}
if p.OS == "" {
p.OS = runtime.GOOS
}
// If we're given a SignatureFile, then attempt to read that.
signature := p.Signature
if p.Signature == "" && p.SignatureFile != "" {
var err error
signature, err = checkSignature(p.SignatureFile)
if err != nil {
return nil, err
}
}
v := u.Query()
v.Set("version", p.Version)
v.Set("arch", p.Arch)
v.Set("os", p.OS)
v.Set("signature", signature)
u.Scheme = "https"
u.Host = "checkpoint-api.hashicorp.com"
u.Path = fmt.Sprintf("/v1/check/%s", p.Product)
u.RawQuery = v.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "HashiCorp/go-checkpoint")
client := cleanhttp.DefaultClient()
// We use a short timeout since checking for new versions is not critical
// enough to block on if checkpoint is broken/slow.
client.Timeout = time.Duration(timeout) * time.Millisecond
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Unknown status: %d", resp.StatusCode)
}
var r io.Reader = resp.Body
if p.CacheFile != "" {
// Make sure the directory holding our cache exists.
if err := os.MkdirAll(filepath.Dir(p.CacheFile), 0755); err != nil {
return nil, err
}
// We have to cache the result, so write the response to the
// file as we read it.
f, err := os.Create(p.CacheFile)
if err != nil {
return nil, err
}
// Write the cache header
if err := writeCacheHeader(f, p.Version); err != nil {
f.Close()
os.Remove(p.CacheFile)
return nil, err
}
defer f.Close()
r = io.TeeReader(r, f)
}
return checkResult(r)
}
// CheckInterval is used to check for a response on a given interval duration.
// The interval is not exact, and checks are randomized to prevent a thundering
// herd. However, it is expected that on average one check is performed per
// interval. The returned channel may be closed to stop background checks.
func CheckInterval(p *CheckParams, interval time.Duration, cb func(*CheckResponse, error)) chan struct{} {
doneCh := make(chan struct{})
if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" {
return doneCh
}
go func() {
for {
select {
case <-time.After(randomStagger(interval)):
resp, err := Check(p)
cb(resp, err)
case <-doneCh:
return
}
}
}()
return doneCh
}
// randomStagger returns an interval that is between 3/4 and 5/4 of
// the given interval. The expected value is the interval.
func randomStagger(interval time.Duration) time.Duration {
stagger := time.Duration(mrand.Int63()) % (interval / 2)
return 3*(interval/4) + stagger
}
func checkCache(current string, path string, d time.Duration) (io.ReadCloser, error) {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist, not a problem
return nil, nil
}
return nil, err
}
if d == 0 {
d = 48 * time.Hour
}
if fi.ModTime().Add(d).Before(time.Now()) {
// Cache is busted, delete the old file and re-request. We ignore
// errors here because re-creating the file is fine too.
os.Remove(path)
return nil, nil
}
// File looks good so far, open it up so we can inspect the contents.
f, err := os.Open(path)
if err != nil {
return nil, err
}
// Check the signature of the file
var sig [4]byte
if err := binary.Read(f, binary.LittleEndian, sig[:]); err != nil {
f.Close()
return nil, err
}
if !reflect.DeepEqual(sig, magicBytes) {
// Signatures don't match. Reset.
f.Close()
return nil, nil
}
// Check the version. If it changed, then rewrite
var length uint32
if err := binary.Read(f, binary.LittleEndian, &length); err != nil {
f.Close()
return nil, err
}
data := make([]byte, length)
if _, err := io.ReadFull(f, data); err != nil {
f.Close()
return nil, err
}
if string(data) != current {
// Version changed, reset
f.Close()
return nil, nil
}
return f, nil
}
func checkResult(r io.Reader) (*CheckResponse, error) {
var result CheckResponse
dec := json.NewDecoder(r)
if err := dec.Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
func checkSignature(path string) (string, error) {
_, err := os.Stat(path)
if err == nil {
// The file exists, read it out
sigBytes, err := ioutil.ReadFile(path)
if err != nil {
return "", err
}
// Split the file into lines
lines := strings.SplitN(string(sigBytes), "\n", 2)
if len(lines) > 0 {
return strings.TrimSpace(lines[0]), nil
}
}
// If this isn't a non-exist error, then return that.
if !os.IsNotExist(err) {
return "", err
}
// The file doesn't exist, so create a signature.
var b [16]byte
n := 0
for n < 16 {
n2, err := rand.Read(b[n:])
if err != nil {
return "", err
}
n += n2
}
signature := fmt.Sprintf(
"%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
// Make sure the directory holding our signature exists.
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return "", err
}
// Write the signature
if err := ioutil.WriteFile(path, []byte(signature+"\n\n"+userMessage+"\n"), 0644); err != nil {
return "", err
}
return signature, nil
}
func writeCacheHeader(f io.Writer, v string) error {
// Write our signature first
if err := binary.Write(f, binary.LittleEndian, magicBytes); err != nil {
return err
}
// Write out our current version length
var length uint32 = uint32(len(v))
if err := binary.Write(f, binary.LittleEndian, length); err != nil {
return err
}
_, err := f.Write([]byte(v))
return err
}
// userMessage is suffixed to the signature file to provide feedback.
var userMessage = `
This signature is a randomly generated UUID used to de-duplicate
alerts and version information. This signature is random, it is
not based on any personally identifiable information. To create
a new signature, you can simply delete this file at any time.
See the documentation for the software using Checkpoint for more
information on how to disable it.
`