Files

194 lines
6.8 KiB
Go

// Package backup uploads encrypted configuration snapshots to object storage.
package backup
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
type S3Config struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
PathStyle bool `json:"pathStyle"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
}
var bucketName = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
var regionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
var ErrObjectExists = errors.New("S3 object already exists")
func (c S3Config) Validate() error {
if !regionName.MatchString(c.Region) {
return errors.New("S3 region is required and must contain lowercase letters, digits or hyphens")
}
if !bucketName.MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") || strings.Contains(c.Bucket, ".-") || strings.Contains(c.Bucket, "-.") {
return errors.New("S3 bucket must be a valid 3-63 character bucket name")
}
if c.Endpoint != "" {
u, err := url.Parse(c.Endpoint)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return errors.New("S3 endpoint must be an HTTP(S) origin without credentials, path, query or fragment")
}
if len(c.Endpoint) > 2048 {
return errors.New("S3 endpoint is too long")
}
}
if len(c.Prefix) > 512 || strings.HasPrefix(c.Prefix, "/") || strings.ContainsAny(c.Prefix, "\\\r\n\x00") {
return errors.New("S3 prefix must be a relative object prefix of at most 512 bytes")
}
for _, segment := range strings.Split(c.Prefix, "/") {
if segment == "." || segment == ".." {
return errors.New("S3 prefix cannot contain dot path segments")
}
}
if strings.TrimSpace(c.AccessKeyID) == "" || len(c.AccessKeyID) > 256 || strings.ContainsAny(c.AccessKeyID, " \t\r\n/,") || len(c.SecretAccessKey) == 0 || len(c.SecretAccessKey) > 4096 || len(c.SessionToken) > 16384 || strings.ContainsAny(c.SessionToken, "\r\n") {
return errors.New("valid S3 access key ID and secret access key are required")
}
return nil
}
// Upload performs a single SigV4-signed PutObject. The caller supplies ciphertext,
// never a plaintext bundle. No ACL is set, so bucket ownership policy is respected.
func Upload(ctx context.Context, c S3Config, key string, ciphertext []byte) error {
if err := c.Validate(); err != nil {
return err
}
req, err := putRequest(ctx, c, key, ciphertext, time.Now().UTC())
if err != nil {
return errors.New("cannot create S3 upload request")
}
client := &http.Client{
Timeout: 30 * time.Second,
// Redirects could disclose signed credentials to another endpoint.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return errors.New("S3 upload cancelled or timed out; verify the object in the bucket before retrying")
}
return errors.New("S3 upload failed; check endpoint, network and TLS certificate")
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode == http.StatusPreconditionFailed || resp.StatusCode == http.StatusConflict {
return ErrObjectExists
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Never forward provider response bodies: they can echo credentials.
return fmt.Errorf("S3 upload returned HTTP %d; check region, bucket and PutObject permission", resp.StatusCode)
}
return nil
}
func putRequest(ctx context.Context, c S3Config, key string, payload []byte, now time.Time) (*http.Request, error) {
return objectRequest(ctx, c, http.MethodPut, key, payload, "", now)
}
func objectRequest(ctx context.Context, c S3Config, method, key string, payload []byte, etag string, now time.Time) (*http.Request, error) {
endpoint := c.Endpoint
if endpoint == "" {
suffix := "amazonaws.com"
if strings.HasPrefix(c.Region, "cn-") {
suffix += ".cn"
}
endpoint = "https://s3." + c.Region + "." + suffix
}
u, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
u.Path = "/" + key
if c.PathStyle {
u.Path = "/" + c.Bucket + u.Path
} else {
u.Host = c.Bucket + "." + u.Host
}
// S3 keeps literal slashes and uses RFC 3986 encoding for every other byte.
u.RawPath = awsPath(u.Path)
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
digest := sha256.Sum256(payload)
payloadHash := hex.EncodeToString(digest[:])
stamp, day := now.UTC().Format("20060102T150405Z"), now.UTC().Format("20060102")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
req.Header.Set("X-Amz-Date", stamp)
names := []string{"host", "x-amz-content-sha256", "x-amz-date"}
if method == http.MethodPut {
if etag == "" {
req.Header.Set("If-None-Match", "*")
names = append(names, "if-none-match")
} else {
req.Header.Set("If-Match", etag)
names = append(names, "if-match")
}
}
if c.SessionToken != "" {
req.Header.Set("X-Amz-Security-Token", c.SessionToken)
names = append(names, "x-amz-security-token")
}
sort.Strings(names)
headers := ""
for _, name := range names {
value := req.Header.Get(name)
if name == "host" {
value = req.URL.Host
}
headers += name + ":" + strings.Join(strings.Fields(value), " ") + "\n"
}
signed := strings.Join(names, ";")
canonical := method + "\n" + u.RawPath + "\n\n" + headers + "\n" + signed + "\n" + payloadHash
hash := sha256.Sum256([]byte(canonical))
scope := day + "/" + c.Region + "/s3/aws4_request"
toSign := "AWS4-HMAC-SHA256\n" + stamp + "\n" + scope + "\n" + hex.EncodeToString(hash[:])
dateKey := mac([]byte("AWS4"+c.SecretAccessKey), day)
regionKey := mac(dateKey, c.Region)
serviceKey := mac(regionKey, "s3")
signingKey := mac(serviceKey, "aws4_request")
signature := hex.EncodeToString(mac(signingKey, toSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+c.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+signature)
return req, nil
}
func mac(key []byte, value string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(value))
return h.Sum(nil)
}
func awsPath(path string) string {
const hexDigits = "0123456789ABCDEF"
var out strings.Builder
for i := 0; i < len(path); i++ {
b := path[i]
if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("-_.~/", rune(b)) {
out.WriteByte(b)
} else {
out.WriteByte('%')
out.WriteByte(hexDigits[b>>4])
out.WriteByte(hexDigits[b&15])
}
}
return out.String()
}