194 lines
6.8 KiB
Go
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()
|
|
}
|