Public source validation / validate (push) Failing after 3m8s
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package live
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/itworx/pulse/internal/auth"
|
|
"github.com/itworx/pulse/internal/correlation"
|
|
"github.com/itworx/pulse/internal/queryplan"
|
|
)
|
|
|
|
const (
|
|
schemaVersion = 1
|
|
defaultMaxMessage = 64 << 10
|
|
defaultMaxSubs = 50
|
|
defaultRateWindow = time.Minute
|
|
defaultRateMessages = 120
|
|
defaultHeartbeat = 15 * time.Second
|
|
defaultIdleTimeout = 45 * time.Second
|
|
defaultWriteTimeout = 5 * time.Second
|
|
maxSubscriptionID = 128
|
|
maxDetailLength = 500
|
|
defaultOutboundMessages = 32
|
|
)
|
|
|
|
var (
|
|
ErrMessageTooLarge = errors.New("live message exceeds size limit")
|
|
ErrRateLimited = errors.New("live message rate limit exceeded")
|
|
)
|
|
|
|
type Incoming struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type string `json:"type"`
|
|
SubscriptionID string `json:"subscriptionId,omitempty"`
|
|
Query json.RawMessage `json:"query,omitempty"`
|
|
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
|
Nonce string `json:"nonce,omitempty"`
|
|
}
|
|
|
|
type Sample struct {
|
|
Series string `json:"series"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Value *float64 `json:"value"`
|
|
Freshness string `json:"freshness"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
type SamplesMessage struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type string `json:"type"`
|
|
SubscriptionID string `json:"subscriptionId"`
|
|
Sequence uint64 `json:"sequence"`
|
|
ServerTime time.Time `json:"serverTime"`
|
|
Samples []Sample `json:"samples"`
|
|
Coalesced bool `json:"coalesced,omitempty"`
|
|
}
|
|
|
|
type StatusMessage struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type string `json:"type"`
|
|
SubscriptionID string `json:"subscriptionId"`
|
|
State string `json:"state"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
type ErrorMessage struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type string `json:"type"`
|
|
SubscriptionID string `json:"subscriptionId,omitempty"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
CorrelationID string `json:"correlationId"`
|
|
}
|
|
|
|
type PongMessage struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type string `json:"type"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
type Sampler interface {
|
|
Sample(context.Context, queryplan.Request) ([]Sample, error)
|
|
}
|
|
|
|
type Handler struct {
|
|
Planner *queryplan.Planner
|
|
Sampler Sampler
|
|
AllowedOrigins []string
|
|
MaxSubscriptions int
|
|
MaxMessages int
|
|
RateWindow time.Duration
|
|
HeartbeatInterval time.Duration
|
|
IdleTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
Now func() time.Time
|
|
Registry *Registry
|
|
}
|
|
|
|
type subscription struct {
|
|
id string
|
|
request queryplan.Request
|
|
interval time.Duration
|
|
nextAt time.Time
|
|
sequence uint64
|
|
lease *Lease
|
|
}
|
|
|
|
type session struct {
|
|
conn *websocket.Conn
|
|
principal auth.Principal
|
|
planner *queryplan.Planner
|
|
sampler Sampler
|
|
maxSubs int
|
|
maxMessages int
|
|
rateWindow time.Duration
|
|
heartbeat time.Duration
|
|
idleTimeout time.Duration
|
|
writeTimeout time.Duration
|
|
registry *Registry
|
|
now func() time.Time
|
|
correlationID string
|
|
mu sync.Mutex
|
|
subscriptions map[string]*subscription
|
|
rateStarted time.Time
|
|
rateCount int
|
|
outbound chan []byte
|
|
}
|
|
|
|
func (h Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
|
principal, ok := auth.PrincipalFromContext(request.Context())
|
|
if !ok {
|
|
http.Error(response, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !auth.Allows(principal.Role, auth.PermissionView) {
|
|
http.Error(response, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
conn, err := websocket.Accept(response, request, &websocket.AcceptOptions{OriginPatterns: h.AllowedOrigins})
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.SetReadLimit(int64(defaultMaxMessage))
|
|
registry := h.Registry
|
|
if registry == nil {
|
|
registry = NewRegistry(h.Sampler, RegistryOptions{})
|
|
}
|
|
// Keep the upgraded transport attached to the authenticated request. The
|
|
// session middleware cancels this context on logout and at the finite
|
|
// absolute session deadline; server/request cancellation must also release
|
|
// subscriptions and the socket.
|
|
ctx, cancel := context.WithCancel(request.Context())
|
|
correlationID := correlation.FromContext(request.Context())
|
|
if correlationID == "" {
|
|
correlationID = correlation.New()
|
|
}
|
|
s := &session{
|
|
conn: conn, principal: principal, planner: h.Planner, sampler: h.Sampler,
|
|
maxSubs: positiveOr(h.MaxSubscriptions, defaultMaxSubs),
|
|
maxMessages: positiveOr(h.MaxMessages, defaultRateMessages),
|
|
rateWindow: durationOr(h.RateWindow, defaultRateWindow),
|
|
heartbeat: durationOr(h.HeartbeatInterval, defaultHeartbeat),
|
|
idleTimeout: durationOr(h.IdleTimeout, defaultIdleTimeout),
|
|
writeTimeout: durationOr(h.WriteTimeout, defaultWriteTimeout),
|
|
registry: registry,
|
|
now: h.Now, correlationID: correlationID, subscriptions: make(map[string]*subscription), outbound: make(chan []byte, defaultOutboundMessages),
|
|
}
|
|
if s.now == nil {
|
|
s.now = func() time.Time { return time.Now().UTC() }
|
|
}
|
|
defer func() {
|
|
cancel()
|
|
s.releaseAll()
|
|
conn.CloseNow()
|
|
}()
|
|
go s.writeLoop(ctx, cancel)
|
|
go s.heartbeatLoop(ctx, cancel)
|
|
go s.sampleLoop(ctx, cancel)
|
|
s.readLoop(ctx)
|
|
}
|
|
|
|
func positiveOr(value, fallback int) int {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func durationOr(value, fallback time.Duration) time.Duration {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (s *session) readLoop(ctx context.Context) {
|
|
for {
|
|
readCtx, cancel := context.WithTimeout(ctx, s.idleTimeout)
|
|
_, payload, err := s.conn.Read(readCtx)
|
|
cancel()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := s.acceptMessage(ctx, payload); err != nil {
|
|
code, detail := "LIVE_MESSAGE_INVALID", "Live bericht is ongeldig."
|
|
if errors.Is(err, ErrMessageTooLarge) {
|
|
code, detail = "LIVE_MESSAGE_LIMIT", "Live bericht overschrijdt de maximale berichtgrootte."
|
|
} else if errors.Is(err, ErrRateLimited) {
|
|
code, detail = "LIVE_RATE_LIMIT", "Te veel live berichten; verbind opnieuw na een korte pauze."
|
|
}
|
|
_ = s.send(ErrorMessage{SchemaVersion: schemaVersion, Type: "error", Code: code, Message: detail, CorrelationID: s.correlationID})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) acceptMessage(ctx context.Context, payload []byte) error {
|
|
if len(payload) > defaultMaxMessage {
|
|
return ErrMessageTooLarge
|
|
}
|
|
s.mu.Lock()
|
|
now := s.now()
|
|
if s.rateStarted.IsZero() || now.Sub(s.rateStarted) >= s.rateWindow {
|
|
s.rateStarted = now
|
|
s.rateCount = 0
|
|
}
|
|
s.rateCount++
|
|
limited := s.rateCount > s.maxMessages
|
|
s.mu.Unlock()
|
|
if limited {
|
|
return ErrRateLimited
|
|
}
|
|
var message Incoming
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&message); err != nil {
|
|
return err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return errors.New("live message has trailing data")
|
|
}
|
|
if message.SchemaVersion != schemaVersion || !validTypeFields(payload, message.Type) {
|
|
return errors.New("live message schema is invalid")
|
|
}
|
|
switch message.Type {
|
|
case "subscribe":
|
|
return s.subscribe(ctx, message)
|
|
case "unsubscribe":
|
|
return s.unsubscribe(message.SubscriptionID)
|
|
case "ping":
|
|
if !validNonce(message.Nonce) {
|
|
return errors.New("ping nonce is invalid")
|
|
}
|
|
return s.send(PongMessage{SchemaVersion: schemaVersion, Type: "pong", Nonce: message.Nonce})
|
|
case "pong":
|
|
if !validNonce(message.Nonce) {
|
|
return errors.New("pong nonce is invalid")
|
|
}
|
|
return nil
|
|
default:
|
|
return errors.New("live message type is unsupported")
|
|
}
|
|
}
|
|
|
|
func validTypeFields(payload []byte, typ string) bool {
|
|
var fields map[string]json.RawMessage
|
|
if json.Unmarshal(payload, &fields) != nil {
|
|
return false
|
|
}
|
|
allowed := map[string]map[string]struct{}{
|
|
"subscribe": {"schemaVersion": {}, "type": {}, "subscriptionId": {}, "query": {}, "intervalSeconds": {}},
|
|
"unsubscribe": {"schemaVersion": {}, "type": {}, "subscriptionId": {}},
|
|
"ping": {"schemaVersion": {}, "type": {}, "nonce": {}},
|
|
"pong": {"schemaVersion": {}, "type": {}, "nonce": {}},
|
|
}
|
|
known, ok := allowed[typ]
|
|
if !ok {
|
|
return false
|
|
}
|
|
for key := range fields {
|
|
if _, ok := known[key]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validSubscriptionID(id string) bool {
|
|
return id != "" && utf8.RuneCountInString(id) <= maxSubscriptionID
|
|
}
|
|
|
|
func validNonce(nonce string) bool {
|
|
return utf8.RuneCountInString(nonce) <= maxSubscriptionID
|
|
}
|
|
|
|
func (s *session) subscribe(ctx context.Context, message Incoming) error {
|
|
if !validSubscriptionID(message.SubscriptionID) || len(message.Query) == 0 || message.IntervalSeconds < 1 || message.IntervalSeconds > 300 {
|
|
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_INVALID", "Abonnement, query of interval is ongeldig.")
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(message.Query, &fields); err != nil || len(fields) > 20 {
|
|
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query moet een begrensd JSON-object zijn.")
|
|
}
|
|
var request queryplan.Request
|
|
decoder := json.NewDecoder(bytes.NewReader(message.Query))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil {
|
|
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query bevat onbekende of ongeldige velden.")
|
|
}
|
|
if s.planner == nil {
|
|
return s.sendError(message.SubscriptionID, "LIVE_QUERY_UNAVAILABLE", "Live queryvalidatie is tijdelijk niet beschikbaar.")
|
|
}
|
|
plan, err := s.planner.Plan(auth.WithPrincipal(ctx, s.principal), request)
|
|
if err != nil {
|
|
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_DENIED", queryErrorDetail(err))
|
|
}
|
|
now := s.now()
|
|
interval := time.Duration(message.IntervalSeconds) * time.Second
|
|
s.mu.Lock()
|
|
duplicate := false
|
|
sessionLimit := len(s.subscriptions) >= s.maxSubs
|
|
if _, exists := s.subscriptions[message.SubscriptionID]; exists {
|
|
duplicate = true
|
|
}
|
|
s.mu.Unlock()
|
|
if duplicate {
|
|
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_CONFLICT", "Subscription-ID bestaat al.")
|
|
}
|
|
if sessionLimit {
|
|
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_LIMIT", "Maximum aantal live abonnementen bereikt.")
|
|
}
|
|
lease, err := s.registry.Acquire(plan.Request, interval)
|
|
if err != nil {
|
|
if errors.Is(err, ErrRegistryLimit) {
|
|
return s.sendError(message.SubscriptionID, "LIVE_REGISTRY_LIMIT", "Maximum aantal gedeelde live queries bereikt.")
|
|
}
|
|
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query kon niet worden genormaliseerd.")
|
|
}
|
|
s.mu.Lock()
|
|
if _, exists := s.subscriptions[message.SubscriptionID]; exists || len(s.subscriptions) >= s.maxSubs {
|
|
s.mu.Unlock()
|
|
lease.Release()
|
|
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_CONFLICT", "Subscription-ID bestaat al.")
|
|
}
|
|
s.subscriptions[message.SubscriptionID] = &subscription{id: message.SubscriptionID, request: plan.Request, interval: interval, nextAt: now.Add(interval), lease: lease}
|
|
s.mu.Unlock()
|
|
return s.send(StatusMessage{SchemaVersion: schemaVersion, Type: "status", SubscriptionID: message.SubscriptionID, State: "subscribed"})
|
|
}
|
|
|
|
func queryErrorDetail(err error) string {
|
|
var plannerError queryplan.Error
|
|
if errors.As(err, &plannerError) {
|
|
return plannerError.Detail
|
|
}
|
|
return "Live query kon niet worden geautoriseerd."
|
|
}
|
|
|
|
func (s *session) unsubscribe(id string) error {
|
|
if !validSubscriptionID(id) {
|
|
return s.sendError(id, "LIVE_SUBSCRIPTION_INVALID", "Subscription-ID is ongeldig.")
|
|
}
|
|
s.mu.Lock()
|
|
sub, exists := s.subscriptions[id]
|
|
delete(s.subscriptions, id)
|
|
s.mu.Unlock()
|
|
if exists && sub.lease != nil {
|
|
sub.lease.Release()
|
|
}
|
|
return s.send(StatusMessage{SchemaVersion: schemaVersion, Type: "status", SubscriptionID: id, State: "unsubscribed"})
|
|
}
|
|
|
|
func (s *session) releaseAll() {
|
|
s.mu.Lock()
|
|
subscriptions := make([]*subscription, 0, len(s.subscriptions))
|
|
for _, sub := range s.subscriptions {
|
|
subscriptions = append(subscriptions, sub)
|
|
}
|
|
s.subscriptions = make(map[string]*subscription)
|
|
s.mu.Unlock()
|
|
for _, sub := range subscriptions {
|
|
if sub.lease != nil {
|
|
sub.lease.Release()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) sendError(subscriptionID, code, message string) error {
|
|
if len(message) > maxDetailLength {
|
|
message = message[:maxDetailLength]
|
|
}
|
|
return s.send(ErrorMessage{SchemaVersion: schemaVersion, Type: "error", SubscriptionID: subscriptionID, Code: code, Message: message, CorrelationID: s.correlationID})
|
|
}
|
|
|
|
func (s *session) send(message any) error {
|
|
payload, err := json.Marshal(message)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(payload) > defaultMaxMessage {
|
|
return ErrMessageTooLarge
|
|
}
|
|
sample := false
|
|
switch message.(type) {
|
|
case SamplesMessage, *SamplesMessage:
|
|
sample = true
|
|
}
|
|
if !sample {
|
|
ctx, cancel := context.WithTimeout(context.Background(), s.writeTimeout)
|
|
defer cancel()
|
|
return s.conn.Write(ctx, websocket.MessageText, payload)
|
|
}
|
|
if s.outbound == nil {
|
|
return ErrOutboundBackpressure
|
|
}
|
|
select {
|
|
case s.outbound <- payload:
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *session) writeLoop(ctx context.Context, cancel context.CancelFunc) {
|
|
for {
|
|
select {
|
|
case payload := <-s.outbound:
|
|
writeCtx, writeCancel := context.WithTimeout(ctx, s.writeTimeout)
|
|
err := s.conn.Write(writeCtx, websocket.MessageText, payload)
|
|
writeCancel()
|
|
if err != nil {
|
|
cancel()
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) heartbeatLoop(ctx context.Context, cancel context.CancelFunc) {
|
|
ticker := time.NewTicker(s.heartbeat)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
pingCtx, pingCancel := context.WithTimeout(ctx, s.writeTimeout)
|
|
err := s.conn.Ping(pingCtx)
|
|
pingCancel()
|
|
if err != nil {
|
|
cancel()
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) sampleLoop(ctx context.Context, cancel context.CancelFunc) {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
now := s.now()
|
|
s.mu.Lock()
|
|
due := make([]*subscription, 0, len(s.subscriptions))
|
|
for _, sub := range s.subscriptions {
|
|
if !now.Before(sub.nextAt) {
|
|
sub.nextAt = now.Add(sub.interval)
|
|
due = append(due, sub)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
for _, sub := range due {
|
|
samples, err := s.registry.Sample(ctx, sub.request)
|
|
if err != nil {
|
|
_ = s.sendError(sub.id, "LIVE_SAMPLE_UNAVAILABLE", "Live sample is tijdelijk niet beschikbaar.")
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
current, exists := s.subscriptions[sub.id]
|
|
if !exists {
|
|
s.mu.Unlock()
|
|
continue
|
|
}
|
|
current.sequence++
|
|
sequence := current.sequence
|
|
s.mu.Unlock()
|
|
if len(samples) > 10000 {
|
|
_ = s.sendError(sub.id, "LIVE_SAMPLE_LIMIT", "Live sample bevat te veel punten.")
|
|
continue
|
|
}
|
|
if err := s.send(SamplesMessage{SchemaVersion: schemaVersion, Type: "samples", SubscriptionID: sub.id, Sequence: sequence, ServerTime: now.UTC(), Samples: samples}); err != nil {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|