132 lines
4.1 KiB
Go
132 lines
4.1 KiB
Go
package auditrelay
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
HeaderKeyID = "X-YoVision-Key-Id"
|
||
|
|
HeaderTimestamp = "X-YoVision-Timestamp"
|
||
|
|
HeaderNonce = "X-YoVision-Nonce"
|
||
|
|
HeaderSignature = "X-YoVision-Signature"
|
||
|
|
RelayPath = "/internal/v1/audit-events:batch"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Client struct {
|
||
|
|
endpoint *url.URL
|
||
|
|
keyID string
|
||
|
|
secret []byte
|
||
|
|
httpClient *http.Client
|
||
|
|
now func() time.Time
|
||
|
|
nonce func() (string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) {
|
||
|
|
endpoint, err := ValidateEndpoint(rawURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if keyID == "" || len(secret) < 32 {
|
||
|
|
return nil, errors.New("audit relay key ID and 32-byte secret are required")
|
||
|
|
}
|
||
|
|
if client == nil {
|
||
|
|
client = &http.Client{Timeout: 10 * time.Second}
|
||
|
|
}
|
||
|
|
return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ValidateEndpoint(rawURL string) (*url.URL, error) {
|
||
|
|
parsed, err := url.Parse(rawURL)
|
||
|
|
if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||
|
|
return nil, errors.New("invalid Bell audit relay URL")
|
||
|
|
}
|
||
|
|
host := parsed.Hostname()
|
||
|
|
ip := net.ParseIP(host)
|
||
|
|
loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
|
||
|
|
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
|
||
|
|
return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback")
|
||
|
|
}
|
||
|
|
return parsed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func CanonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||
|
|
digest := sha256.Sum256(body)
|
||
|
|
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
func Signature(secret []byte, canonical string) string {
|
||
|
|
mac := hmac.New(sha256.New, secret)
|
||
|
|
_, _ = mac.Write([]byte(canonical))
|
||
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
func randomNonce() (string, error) {
|
||
|
|
value := make([]byte, 16)
|
||
|
|
if _, err := rand.Read(value); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) {
|
||
|
|
if len(events) < 1 || len(events) > MaxBatchSize {
|
||
|
|
return nil, errors.New("audit relay batch must contain 1 to 100 events")
|
||
|
|
}
|
||
|
|
body, err := json.Marshal(BatchRequest{Events: events})
|
||
|
|
if err != nil || len(body) > MaxBodyBytes {
|
||
|
|
return nil, errors.New("encode audit relay batch")
|
||
|
|
}
|
||
|
|
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10)
|
||
|
|
nonce, err := c.nonce()
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("generate audit relay nonce")
|
||
|
|
}
|
||
|
|
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("create audit relay request")
|
||
|
|
}
|
||
|
|
request.Header.Set("Content-Type", "application/json")
|
||
|
|
request.Header.Set(HeaderKeyID, c.keyID)
|
||
|
|
request.Header.Set(HeaderTimestamp, timestamp)
|
||
|
|
request.Header.Set(HeaderNonce, nonce)
|
||
|
|
request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body)))
|
||
|
|
response, err := c.httpClient.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("send audit relay request")
|
||
|
|
}
|
||
|
|
defer response.Body.Close()
|
||
|
|
if response.StatusCode != http.StatusOK {
|
||
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||
|
|
return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode)
|
||
|
|
}
|
||
|
|
decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
var decoded BatchResponse
|
||
|
|
if err := decoder.Decode(&decoded); err != nil {
|
||
|
|
return nil, errors.New("decode audit relay response")
|
||
|
|
}
|
||
|
|
var trailing any
|
||
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||
|
|
return nil, errors.New("audit relay response contains trailing data")
|
||
|
|
}
|
||
|
|
return decoded.Results, nil
|
||
|
|
}
|