Files
yovision/Sense/internal/auditrelay/client.go
T

132 lines
4.1 KiB
Go
Raw Normal View History

2026-08-11 00:24:32 +08:00
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
}