package service import ( "context" "strings" "time" statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1" "github.com/bergabruh/stats-gateway/internal/auth" "github.com/bergabruh/stats-gateway/internal/storage" "github.com/bergabruh/stats-gateway/internal/tunnel" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) // Service implements statsv1.SpeedStatusServer. type Service struct { statsv1.UnimplementedSpeedStatusServer store *storage.Store verifier *auth.Verifier tunnel *tunnel.Handler } // New creates a Service with the given storage, JWT verifier, and tunnel handler. func New(st *storage.Store, v *auth.Verifier, t *tunnel.Handler) *Service { return &Service{store: st, verifier: v, tunnel: t} } func (s *Service) RecentResults(f *statsv1.Filter, stream statsv1.SpeedStatus_RecentResultsServer) error { limit := int(f.Limit) if limit <= 0 || limit > 1000 { limit = 100 } var since time.Time if f.Since != "" { t, err := time.Parse(time.RFC3339, f.Since) if err != nil { return status.Errorf(codes.InvalidArgument, "since: %v", err) } since = t } samples, err := s.store.QueryRecent(stream.Context(), f.Node, limit, since) if err != nil { return status.Errorf(codes.Internal, "query: %v", err) } for _, r := range samples { err := stream.Send(&statsv1.Sample{ Node: r.Node, Timestamp: r.Timestamp.Format(time.RFC3339Nano), HttpLatencyMs: r.HTTPLatencyMs, TcpRttMs: r.TCPRttMs, DnsResolveMs: r.DNSResolveMs, TlsHandshakeMs: r.TLSHandshakeMs, ThroughputKbps: r.ThroughputKbps, }) if err != nil { return err } } return nil } // aggregateBounds defines bucket upper bounds (ms) used by Aggregate. var aggregateBounds = []float64{25, 50, 100, 250, 500, 1000, 5000} func (s *Service) Aggregate(ctx context.Context, r *statsv1.Range) (*statsv1.Histogram, error) { since, _ := time.Parse(time.RFC3339, r.Since) until, _ := time.Parse(time.RFC3339, r.Until) if until.IsZero() { until = time.Now().UTC() } if since.IsZero() { return nil, status.Error(codes.InvalidArgument, "since required") } samples, err := s.store.QueryRange(ctx, r.Node, since, until) if err != nil { return nil, status.Errorf(codes.Internal, "query: %v", err) } counts := make([]int64, len(aggregateBounds)) for _, sm := range samples { for i, b := range aggregateBounds { if sm.HTTPLatencyMs <= b { counts[i]++ break } } } h := &statsv1.Histogram{} for i, b := range aggregateBounds { h.Buckets = append(h.Buckets, &statsv1.Bucket{UpperMs: b, Count: counts[i]}) } return h, nil } // Tunnel authenticates the caller via Bearer JWT then delegates to the tunnel handler. func (s *Service) Tunnel(stream statsv1.SpeedStatus_TunnelServer) error { md, ok := metadata.FromIncomingContext(stream.Context()) if !ok { return status.Error(codes.Unauthenticated, "no metadata") } authH := md.Get("authorization") if len(authH) == 0 { return status.Error(codes.Unauthenticated, "no auth") } tok := strings.TrimPrefix(authH[0], "Bearer ") if tok == authH[0] { return status.Error(codes.Unauthenticated, "expected Bearer") } if _, err := s.verifier.Verify(tok); err != nil { return status.Errorf(codes.Unauthenticated, "auth: %v", err) } return s.tunnel.Serve(stream) }