diff --git a/backend/cmd/api/env.go b/backend/cmd/api/env.go index 7f63a2784..02055c6dc 100644 --- a/backend/cmd/api/env.go +++ b/backend/cmd/api/env.go @@ -26,7 +26,6 @@ import ( "github.com/bcc-code/bcc-media-platform/backend/search" "github.com/bcc-code/bcc-media-platform/backend/auth0" - "github.com/bcc-code/bcc-media-platform/backend/streamtoken" "github.com/joho/godotenv" "github.com/samber/lo" ) @@ -95,7 +94,6 @@ type envConfig struct { Port string Auth0 auth0.Config CDNConfig cdnConfig - Livestream livestreamConfig StreamProxy streamProxyConfig Secrets serviceSecrets Redis utils.RedisConfig @@ -130,54 +128,22 @@ type adminConfig struct { type cdnConfig struct { ImageCDNDomain string Vod2Domain string - LegacyVODDomain string FilesDomain string AWSSigningKeyPath string AWSSigningKeyID string } -// livestreamConfig holds the CloudFront key pair used to sign the livestream -// manifest URL. This is a separate key pair from the VOD/file signing key in -// cdnConfig. It satisfies signing.CloudFrontConfig. -type livestreamConfig struct { - SigningKeyPath string - SigningKeyID string -} - -func (c livestreamConfig) GetAwsSigningKeyPath() string { return c.SigningKeyPath } -func (c livestreamConfig) GetAwsSigningKeyID() string { return c.SigningKeyID } - +// streamProxyConfig is what the API needs to mint stream-proxy URLs (HS256 JWT +// on the proxy's public host). It satisfies streamtoken.Config. type streamProxyConfig struct { - JWTSecret string - JWTIssuer string - Domain string - PrimaryProvider streamtoken.Provider + JWTSecret string + JWTIssuer string + Domain string } -func (c streamProxyConfig) GetStreamJWTSecret() string { return c.JWTSecret } -func (c streamProxyConfig) GetStreamJWTIssuer() string { return c.JWTIssuer } -func (c streamProxyConfig) GetStreamProxyDomain() string { return c.Domain } -func (c streamProxyConfig) GetStreamPrimaryProvider() streamtoken.Provider { return c.PrimaryProvider } - -// parsePrimaryProvider maps the user-facing STREAM_PRIMARY_PROVIDER values -// ("cloudfront", "streamproxy") to the streamtoken.Provider used as the JWT -// `provider` claim. The claim values are different ("cloudfront", "ioriver") -// because they are the stream-proxy's internal vocabulary; the API exposes -// the same routing decision under a name that abstracts away the -// underlying signer technology. Empty raw → ProviderUnspecified (signer -// substitutes its own default). Unknown raw passes through so the signer's -// validity check rejects it with a clear error. -func parsePrimaryProvider(raw string) streamtoken.Provider { - switch raw { - case "": - return streamtoken.ProviderUnspecified - case "cloudfront": - return streamtoken.ProviderCloudFront - case "streamproxy": - return streamtoken.ProviderIoriver - } - return streamtoken.Provider(raw) -} +func (c streamProxyConfig) GetStreamJWTSecret() string { return c.JWTSecret } +func (c streamProxyConfig) GetStreamJWTIssuer() string { return c.JWTIssuer } +func (c streamProxyConfig) GetStreamProxyDomain() string { return c.Domain } type awsConfig struct { TempBucket string // Things put here are automatically removed @@ -235,11 +201,6 @@ func (c cdnConfig) GetVOD2Domain() string { return c.Vod2Domain } -// GetLegacyVODDomain returns the legacy VOD domain -func (c cdnConfig) GetLegacyVODDomain() string { - return c.LegacyVODDomain -} - // GetFilesCDNDomain returns the configured FilesCDNDomain func (c cdnConfig) GetFilesCDNDomain() string { return c.FilesDomain @@ -316,17 +277,11 @@ func getEnvConfig() envConfig { FilesDomain: os.Getenv("FILES_CDN_DOMAIN"), AWSSigningKeyID: os.Getenv("CF_SIGNING_KEY_ID"), AWSSigningKeyPath: os.Getenv("CF_SIGNING_KEY_PATH"), - LegacyVODDomain: os.Getenv("LEGACY_CDN_DOMAIN"), - }, - Livestream: livestreamConfig{ - SigningKeyID: os.Getenv("LIVESTREAM_SIGNING_KEY_ID"), - SigningKeyPath: os.Getenv("LIVESTREAM_SIGNING_KEY_PATH"), }, StreamProxy: streamProxyConfig{ - JWTSecret: os.Getenv("STREAM_JWT_SECRET"), - JWTIssuer: os.Getenv("STREAM_JWT_ISSUER"), - Domain: os.Getenv("STREAM_PROXY_DOMAIN"), - PrimaryProvider: parsePrimaryProvider(os.Getenv("STREAM_PRIMARY_PROVIDER")), + JWTSecret: os.Getenv("STREAM_JWT_SECRET"), + JWTIssuer: os.Getenv("STREAM_JWT_ISSUER"), + Domain: os.Getenv("STREAM_PROXY_DOMAIN"), }, Secrets: serviceSecrets{ Directus: os.Getenv("SERVICE_SECRET_DIRECTUS"), diff --git a/backend/cmd/api/env.sample b/backend/cmd/api/env.sample index aa3cb99f7..c465f18d5 100644 --- a/backend/cmd/api/env.sample +++ b/backend/cmd/api/env.sample @@ -21,8 +21,6 @@ IMAGE_CDN_DOMAIN=brunstadtv.imgix.net VOD2_CDN_DOMAIN=vod2.brunstad.tv FILES_CDN_DOMAIN=files.brunstad.tv CF_SIGNING_KEY_ID= -LIVESTREAM_SIGNING_KEY_ID= -LIVESTREAM_SIGNING_KEY_PATH= AZ_SIGNING_KEY= # Stream proxy: HMAC secret + issuer the API uses to mint stream JWTs. @@ -32,14 +30,11 @@ STREAM_JWT_ISSUER= # Public hostname clients use to reach the stream proxy. # For local dev, point at the locally-running cmd/stream-proxy (default port 8081). STREAM_PROXY_DOMAIN=localhost:8081 -# Optional. Selects how the API delivers stream URLs to clients: -# "cloudfront" (or empty) → API signs CloudFront URLs directly; the -# stream-proxy is NOT in the request path. -# "streamproxy" → API returns a stream-proxy URL with an HS256 -# JWT; the proxy signs upstream to ioriver. -# The Unleash `stream-proxy:legacy` flag, when set on a request, overrides -# this and forces the CloudFront-direct signer for that request only. -STREAM_PRIMARY_PROVIDER= +# All stream URLs (VOD and live) are stream-proxy URLs. Which upstream CDN +# identity the proxy signs for is the JWT `provider` claim: ioriver by default; +# the Unleash `cdn-provider` (VOD) / `live-cdn-provider` (live) variants +# `ioriver` / `cloudfront`, forwarded by clients in x-feature-flags, override it +# per request. # Unleash usage reporting. The clients evaluate flags and forward them to us as # the x-feature-flags header, so the API runs no SDK — it only posts metrics diff --git a/backend/cmd/api/handlers.go b/backend/cmd/api/handlers.go index 265bc2dc0..c660d37d6 100644 --- a/backend/cmd/api/handlers.go +++ b/backend/cmd/api/handlers.go @@ -49,8 +49,6 @@ func graphqlHandler( emailService *email.Service, fileSigner *signing.CloudFrontSigner, streamSigner *streamtoken.Signer, - legacyStreamSigner *signing.CloudFrontStreamSigner, - livestreamSigner *signing.CloudFrontSigner, config envConfig, s3client *s3.Client, analyticsSalt string, @@ -71,9 +69,6 @@ func graphqlHandler( EmailService: emailService, FileSigner: fileSigner, StreamURLSigner: streamSigner, - LegacyStreamSigner: legacyStreamSigner, - LivestreamSigner: livestreamSigner, - PrimaryStreamProvider: config.StreamProxy.PrimaryProvider, S3Client: s3client, APIConfig: config.CDNConfig, AWSConfig: config.AWS, diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index df1b082a6..7b932d2d3 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -195,23 +195,10 @@ func main() { if err != nil { log.L.Panic().Err(err).Msg("failed to init cloudfront file signer") } - legacyStreamSigner := signing.NewCloudFrontStreamSigner(fileSigner, config.CDNConfig.GetVOD2Domain()) streamSigner, err := streamtoken.NewSigner(config.StreamProxy) if err != nil { log.L.Panic().Err(err).Msg("failed to init stream-proxy signer") } - // The livestream uses its own CloudFront key pair. Optional: when it isn't - // configured the API still boots and live.isOnline works, but live.url is - // omitted (rather than panicking environments without the key deployed). - var livestreamSigner *signing.CloudFrontSigner - if config.Livestream.GetAwsSigningKeyPath() != "" { - livestreamSigner, err = signing.NewCloudFrontSigner(config.Livestream) - if err != nil { - log.L.Panic().Err(err).Msg("failed to init livestream signer") - } - } else { - log.L.Warn().Msg("livestream signing key not configured (LIVESTREAM_SIGNING_KEY_PATH); live.url will be unavailable") - } queries := sqlc.New(db) queries.SetImageCDNDomain(config.CDNConfig.ImageCDNDomain) authClient := auth0.New(config.Auth0) @@ -343,8 +330,6 @@ func main() { emailService, fileSigner, streamSigner, - legacyStreamSigner, - livestreamSigner, config, s3Client, config.AnalyticsSalt, diff --git a/backend/cmd/jobs/env.go b/backend/cmd/jobs/env.go index 16ea658a6..2e32b3e04 100644 --- a/backend/cmd/jobs/env.go +++ b/backend/cmd/jobs/env.go @@ -57,6 +57,18 @@ func (c cdnConfig) GetAwsSigningKeyID() string { return c.AWSSigningKeyID } +// streamProxyConfig is what the export needs to mint stream-proxy URLs. The +// values MUST match cmd/api and cmd/stream-proxy. It satisfies streamtoken.Config. +type streamProxyConfig struct { + JWTSecret string + JWTIssuer string + Domain string +} + +func (c streamProxyConfig) GetStreamJWTSecret() string { return c.JWTSecret } +func (c streamProxyConfig) GetStreamJWTIssuer() string { return c.JWTIssuer } +func (c streamProxyConfig) GetStreamProxyDomain() string { return c.Domain } + type envConfig struct { AWS awsConfig AzureStorage files.AzureConfig @@ -76,6 +88,7 @@ type envConfig struct { VideoManipulator videomanipulatorConfig Phrase phrase.Config CDNConfig cdnConfig + StreamProxy streamProxyConfig } func getEnvConfig() envConfig { @@ -173,5 +186,10 @@ func getEnvConfig() envConfig { AWSSigningKeyID: os.Getenv("CF_SIGNING_KEY_ID"), AWSSigningKeyPath: os.Getenv("CF_SIGNING_KEY_PATH"), }, + StreamProxy: streamProxyConfig{ + JWTSecret: os.Getenv("STREAM_JWT_SECRET"), + JWTIssuer: os.Getenv("STREAM_JWT_ISSUER"), + Domain: os.Getenv("STREAM_PROXY_DOMAIN"), + }, } } diff --git a/backend/cmd/jobs/env.sample b/backend/cmd/jobs/env.sample index 7751b9870..0cb801dc2 100644 --- a/backend/cmd/jobs/env.sample +++ b/backend/cmd/jobs/env.sample @@ -45,8 +45,13 @@ AZURE_STORAGE_CONTAINER=images VIDEOMANIPULATOR_BASE_URL=http://localhost:8005/ VIDEOMANIPULATOR_API_KEY= -# CDN signing (CloudFront, used for downloadable file URLs and the legacy -# stream-URL form embedded in offline-export bundles). +# CDN signing (CloudFront, used for downloadable file URLs). VOD2_CDN_DOMAIN= CF_SIGNING_KEY_ID= CF_SIGNING_KEY_PATH= + +# Stream proxy: HMAC secret + issuer + public host used to mint the stream URLs +# embedded in offline-export bundles. These MUST match cmd/api and cmd/stream-proxy. +STREAM_JWT_SECRET= +STREAM_JWT_ISSUER= +STREAM_PROXY_DOMAIN= diff --git a/backend/cmd/jobs/main.go b/backend/cmd/jobs/main.go index 992d0b003..de96c05f8 100644 --- a/backend/cmd/jobs/main.go +++ b/backend/cmd/jobs/main.go @@ -23,6 +23,7 @@ import ( "github.com/bcc-code/bcc-media-platform/backend/signing" "github.com/bcc-code/bcc-media-platform/backend/sqlc" "github.com/bcc-code/bcc-media-platform/backend/statistics" + "github.com/bcc-code/bcc-media-platform/backend/streamtoken" "github.com/bcc-code/bcc-media-platform/backend/translations" "github.com/bcc-code/bcc-media-platform/backend/translations/phrase" "github.com/bcc-code/bcc-media-platform/backend/utils" @@ -150,7 +151,10 @@ func main() { if err != nil { log.L.Fatal().Err(err).Msg("failed to init cloudfront file signer") } - legacyStreamSigner := signing.NewCloudFrontStreamSigner(fileSigner, config.CDNConfig.GetVOD2Domain()) + streamSigner, err := streamtoken.NewSigner(config.StreamProxy) + if err != nil { + log.L.Fatal().Err(err).Msg("failed to init stream-proxy signer") + } services := server.ExternalServices{ Database: db, @@ -168,7 +172,7 @@ func main() { CDNConfigProvider: config.CDNConfig, BatchLoaders: loaders.InitBatchLoaders(queries, nil), FileSigner: fileSigner, - LegacyStreamSigner: legacyStreamSigner, + StreamSigner: streamSigner, } handlers := server.NewServer(services, serverConfig) diff --git a/backend/cmd/jobs/server/services.go b/backend/cmd/jobs/server/services.go index b41e9e470..b0e19db10 100644 --- a/backend/cmd/jobs/server/services.go +++ b/backend/cmd/jobs/server/services.go @@ -2,10 +2,12 @@ package server import ( "database/sql" + "github.com/bcc-code/bcc-media-platform/backend/common" "github.com/bcc-code/bcc-media-platform/backend/export" "github.com/bcc-code/bcc-media-platform/backend/loaders" "github.com/bcc-code/bcc-media-platform/backend/signing" + "github.com/bcc-code/bcc-media-platform/backend/streamtoken" "github.com/bcc-code/bcc-media-platform/backend/translations" "github.com/aws/aws-sdk-go-v2/service/mediapackagevod" @@ -38,7 +40,7 @@ type ExternalServices struct { CDNConfigProvider export.CDNConfig BatchLoaders *loaders.BatchLoaders FileSigner *signing.CloudFrontSigner - LegacyStreamSigner *signing.CloudFrontStreamSigner + StreamSigner *streamtoken.Signer } // GetDatabase as stored in the struct @@ -112,6 +114,7 @@ func (e ExternalServices) GetFileSigner() *signing.CloudFrontSigner { return e.FileSigner } -func (e ExternalServices) GetLegacyStreamSigner() *signing.CloudFrontStreamSigner { - return e.LegacyStreamSigner +// GetStreamSigner returns the stream-proxy URL signer used by the export. +func (e ExternalServices) GetStreamSigner() *streamtoken.Signer { + return e.StreamSigner } diff --git a/backend/export/export.go b/backend/export/export.go index 044384c2e..8657cfe83 100644 --- a/backend/export/export.go +++ b/backend/export/export.go @@ -18,7 +18,6 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/bcc-code/bcc-media-platform/backend/graph/api/model" - "github.com/bcc-code/bcc-media-platform/backend/signing" "github.com/bcc-code/bcc-media-platform/backend/utils" "github.com/cloudevents/sdk-go/v2/event" "github.com/google/uuid" @@ -68,7 +67,7 @@ type serviceProvider interface { GetLoadersForRoles(roles []string) *loaders.LoadersWithPermissions GetPersonalizedLoaders(roles []string, langPreferences common.LanguagePreferences) *loaders.PersonalizedLoaders GetS3Client() *s3.Client - GetLegacyStreamSigner() *signing.CloudFrontStreamSigner + GetStreamSigner() *streamtoken.Signer GetCDNConfig() CDNConfig } @@ -79,7 +78,7 @@ type serviceProviderAPI interface { GetFilteredLoaders(ctx context.Context) *loaders.LoadersWithPermissions GetPersonalizedLoaders(ctx context.Context) *loaders.PersonalizedLoaders GetQueries() *sqlc.Queries - GetLegacyStreamSigner() *signing.CloudFrontStreamSigner + GetStreamSigner() *streamtoken.Signer GetS3Client() *s3.Client } @@ -239,10 +238,10 @@ func exportEpisodes(ctx context.Context, batchLoaders *loaders.BatchLoaders, fil } // exportStreams writes per-episode stream URLs into the offline-export -// SQLite db. Exports always use the legacy CloudFront-signed URL form because -// jobs run without a request context to evaluate the per-user feature flag -// that switches between the proxy and legacy paths. -func exportStreams(ctx context.Context, ls *loaders.BatchLoaders, streamSigner *signing.CloudFrontStreamSigner, liteQueries *sqlexport.Queries, episodeIDs []int) error { +// SQLite db as stream-proxy URLs. Exports run without a request context to +// evaluate the per-user `cdn-provider` flag, so they always use the signer's +// default upstream identity (streamtoken.DefaultPrimaryProvider). +func exportStreams(ctx context.Context, ls *loaders.BatchLoaders, streamSigner *streamtoken.Signer, liteQueries *sqlexport.Queries, episodeIDs []int) error { episodes, err := ls.EpisodeLoader.GetMany(ctx, episodeIDs) if err != nil { @@ -512,7 +511,7 @@ func HandleExportMessage(ctx context.Context, s serviceProvider, tempBucketNeme s.GetLoaders(), s.GetLoadersForRoles(fetchedEntry.UserGroups), s.GetPersonalizedLoaders(fetchedEntry.UserGroups, langPreferences), - s.GetLegacyStreamSigner(), + s.GetStreamSigner(), s.GetS3Client(), s.GetDatabase(), ) @@ -578,7 +577,7 @@ func DoExport(ctx context.Context, q serviceProviderAPI, bucketName string, user q.GetLoaders(), q.GetFilteredLoaders(ctx), q.GetPersonalizedLoaders(ctx), - q.GetLegacyStreamSigner(), + q.GetStreamSigner(), q.GetS3Client(), q.GetDatabase(), ) @@ -593,7 +592,7 @@ func doExport( batchLoaders *loaders.BatchLoaders, roleLoaders *loaders.LoadersWithPermissions, personalizedLoaders *loaders.PersonalizedLoaders, - streamSigner *signing.CloudFrontStreamSigner, + streamSigner *streamtoken.Signer, s3Client *s3.Client, pgSql *sql.DB, ) (string, error) { diff --git a/backend/graph/api/calendar-resolver.go b/backend/graph/api/calendar-resolver.go index 5ab2f94c7..18977f000 100644 --- a/backend/graph/api/calendar-resolver.go +++ b/backend/graph/api/calendar-resolver.go @@ -11,6 +11,7 @@ import ( "github.com/bcc-code/bcc-media-platform/backend/graph/api/model" "github.com/bcc-code/bcc-media-platform/backend/loaders" "github.com/bcc-code/bcc-media-platform/backend/memorycache" + "github.com/bcc-code/bcc-media-platform/backend/streamtoken" "github.com/bcc-code/bcc-media-platform/backend/user" "github.com/bcc-code/bcc-media-platform/backend/utils" "github.com/samber/lo" @@ -45,14 +46,14 @@ type bufferWindow struct { entry *common.CalendarEntry livestreamURL string until time.Time - signing liveSigning + provider streamtoken.Provider } // bufferWindowForEntry resolves the buffer playback window for the calendar entry // with the given id, or nil when no buffer should be offered to the caller. It is // the single gate shared by the bufferUrl and bufferAvailableUntil fields, so the -// two are always consistent. A buffer is offered only when: a livestream signing -// key and URL are configured; the caller passes the permission-group gate +// two are always consistent. A buffer is offered only when: a livestream URL is +// configured; the caller passes the permission-group gate // (BufferAllowed — empty groups → all BCC members, else intersected with the // caller's roles in SQL); the linked episode is not yet watchable for the caller — // i.e. either not published, or published but still "locked" (publish date in the @@ -60,11 +61,6 @@ type bufferWindow struct { // then the canonical way to watch; buffer_available_hours > 0; and now is within // [entry.start, entry.end + hours] (hours capped at maxBufferAvailableHours). func (r *Resolver) bufferWindowForEntry(ctx context.Context, id string) (*bufferWindow, error) { - ls := r.resolveLiveSigning(ctx) - if !r.canSignLive(ls) { - return nil, nil - } - conf, err := withCacheAndTimestamp(ctx, "global_config", r.Queries.GetGlobalConfig, time.Second*30, nil) if err != nil { return nil, err @@ -107,7 +103,7 @@ func (r *Resolver) bufferWindowForEntry(ctx context.Context, id string) (*buffer return nil, nil } - return &bufferWindow{entry: entry, livestreamURL: conf.LivestreamURL, until: until, signing: ls}, nil + return &bufferWindow{entry: entry, livestreamURL: conf.LivestreamURL, until: until, provider: r.pickLiveProvider(ctx)}, nil } // bufferPlaybackWindow returns the [start, end] window the buffer (start-over) @@ -186,7 +182,7 @@ func (r *Resolver) bufferForEntry(ctx context.Context, id string) (*model.Calend return nil, err } start, end := bufferPlaybackWindow(w.entry) - url, err := r.signedBufferURL(w.signing, w.livestreamURL, start, end, w.until) + url, err := r.signedBufferURL(w.livestreamURL, start, end, w.until, w.provider) if err != nil { return nil, err } diff --git a/backend/graph/api/episodes.resolvers.go b/backend/graph/api/episodes.resolvers.go index b2003d77d..a6485493d 100644 --- a/backend/graph/api/episodes.resolvers.go +++ b/backend/graph/api/episodes.resolvers.go @@ -133,7 +133,7 @@ func (r *episodeResolver) Streams(ctx context.Context, obj *model.Episode) ([]*m r.GetLoaders().AssetStreamsLoader.LoadMany(ctx, lo.Values(e.Assets)) - streamSigner, selectedCdn := r.pickStreamSigner(ctx) + selectedCdn := r.pickStreamProvider(ctx) if e.AssetID.Valid { r.GetLoaders().AssetStreamsLoader.Load(ctx, int(e.AssetID.Int64)) @@ -144,7 +144,7 @@ func (r *episodeResolver) Streams(ctx context.Context, obj *model.Episode) ([]*m } for _, s := range streams { - stream, err := model.StreamFrom(ctx, streamSigner, s, selectedCdn) + stream, err := model.StreamFrom(ctx, r.StreamURLSigner, s, selectedCdn) if err != nil { return nil, err } @@ -161,7 +161,7 @@ func (r *episodeResolver) Streams(ctx context.Context, obj *model.Episode) ([]*m } for _, s := range streams { - stream, err := model.StreamFrom(ctx, streamSigner, s, selectedCdn) + stream, err := model.StreamFrom(ctx, r.StreamURLSigner, s, selectedCdn) if err != nil { return nil, err } diff --git a/backend/graph/api/live.go b/backend/graph/api/live.go index b70987fec..ebd706378 100644 --- a/backend/graph/api/live.go +++ b/backend/graph/api/live.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bcc-code/bcc-media-platform/backend/log" + "github.com/bcc-code/bcc-media-platform/backend/streamtoken" ) // livestreamURLExpiry is how long a signed livestream manifest URL stays valid. @@ -22,13 +23,6 @@ const livestreamURLExpiry = 6 * time.Hour // (requesting a `start` older than the retained window returns an error). const maxLivestreamStartAge = 90 * time.Minute -// maxLivestreamURLAgeFromStart caps how long after the (clamped) start time a -// signed URL stays valid. It bounds the URL's lifetime to the relevant program -// window rather than the full livestreamURLExpiry. -// -// This is because manifests > 2h grow over the lambda limit. -const maxLivestreamURLAgeFromStart = 2 * time.Hour - // liveURL is the cached, signed livestream URL plus its expiry. It is not // user-specific: the stream and signing key are global, so a single cache entry // serves every permitted caller. @@ -42,20 +36,10 @@ type liveURL struct { // ago, matching the replay buffer's padded view of the program window — // inserts the AWS Elemental MediaPackage start-over `start` path element so // playback joins from the program's start (clamped to at most -// maxLivestreamStartAge in the past). On the -// legacy path the URL's validity is capped at maxLivestreamURLAgeFromStart past -// that start; on the proxy path it keeps the full livestreamURLExpiry, and the -// returned ExpiresAt is earlier than the token's real expiry (see -// signLiveManifestWith). -// -// It returns nil (with a nil error) when no signer is configured for the -// selected path, so the caller serves the online flag only. +// maxLivestreamStartAge in the past). The URL is valid for livestreamURLExpiry; +// the returned ExpiresAt is earlier than the token's real expiry (see +// signLiveManifest). func (r *Resolver) signedLiveURL(ctx context.Context, livestreamURL string) (*liveURL, error) { - ls := r.resolveLiveSigning(ctx) - if !r.canSignLive(ls) { - return nil, nil - } - now := time.Now() entry, err := r.Queries.GetCurrentCalendarEntry(ctx, now.Add(-bufferLeadOut)) if err != nil { @@ -69,8 +53,7 @@ func (r *Resolver) signedLiveURL(ctx context.Context, livestreamURL string) (*li start = &s } - ttl := livestreamExpiresAt(start, now, ls.useProxy).Sub(now) - signedURL, expiresAt, err := r.signLiveManifestWith(ls, livestreamURL, ttl) + signedURL, expiresAt, err := r.signLiveManifest(livestreamURL, livestreamURLExpiry, r.pickLiveProvider(ctx)) if err != nil { log.L.Error().Err(err).Str("livestreamURL", livestreamURL).Msg("signedLiveURL: failed to sign livestream URL") return nil, err @@ -92,15 +75,8 @@ func (r *Resolver) signedLiveURL(ctx context.Context, livestreamURL string) (*li // that entry's window. Unlike signedLiveURL it does not clamp the start: the // buffer is meant to replay the real program window, and the origin's start-over // retention is expected to cover it. -// -// Note: on the legacy path a window (end-start) longer than ~2h hits the -// Lambda@Edge manifest-size limit described on maxLivestreamURLAgeFromStart. The -// stream-proxy has no such limit. Entries are almost always shorter regardless; -// revisit (e.g. chunked playback) if long buffers are needed rather than -// truncating the window here. -func (r *Resolver) signedBufferURL(ls liveSigning, livestreamURL string, start, end, expiresAt time.Time) (string, error) { - now := time.Now() - signedURL, _, err := r.signLiveManifestWith(ls, livestreamURL, expiresAt.Sub(now)) +func (r *Resolver) signedBufferURL(livestreamURL string, start, end, expiresAt time.Time, provider streamtoken.Provider) (string, error) { + signedURL, _, err := r.signLiveManifest(livestreamURL, time.Until(expiresAt), provider) if err != nil { log.L.Error().Err(err).Str("livestreamURL", livestreamURL).Msg("signedBufferURL: failed to sign livestream URL") return "", err @@ -108,53 +84,21 @@ func (r *Resolver) signedBufferURL(ls liveSigning, livestreamURL string, start, return appendTimeShiftTags(signedURL, start, &end), nil } -// signLiveManifestWith signs the livestream manifest URL for ttl using the -// already-resolved signing decision (see resolveLiveSigning). It routes through -// the stream-proxy (multi-CDN via ioriver) when the proxy path was selected, and -// otherwise falls back to the legacy CloudFront canned-policy signer, whose URLs -// are rewritten per-request by the Lambda@Edge manifest handler. It returns the -// signed URL and its expiry, before any MediaPackage time-shift tags are -// appended by the caller. On the proxy path that expiry is the advertised one, -// which streamtoken.SignLiveURL deliberately reports earlier than the JWT's -// `exp` claim; the legacy signer's expiry is exactly now+ttl. +// signLiveManifest signs the livestream manifest URL for ttl as a stream-proxy +// URL (multi-CDN via the proxy; provider names the upstream identity, see +// pickLiveProvider). It returns the signed URL and its advertised expiry, before +// any MediaPackage time-shift tags are appended by the caller. That expiry is +// deliberately earlier than the JWT's `exp` claim (see streamtoken.SignLiveURL). // -// The CDN/CloudFront signature signs the resource path, not the query, so the +// The JWT authorizes the manifest's directory, not the exact query, so the // caller can safely append `start`/`end` time-shift params to the returned URL -// without invalidating it (see appendTimeShiftTags). On the proxy path those -// params travel to the proxy, which forwards them to the upstream manifest. -func (r *Resolver) signLiveManifestWith(ls liveSigning, livestreamURL string, ttl time.Duration) (string, time.Time, error) { - if ls.useProxy { - u, err := url.Parse(livestreamURL) - if err != nil { - return "", time.Time{}, err - } - return ls.proxy.SignLiveURL(u.Path, ttl, ls.provider) - } - return r.LivestreamSigner.SignURLCanned(livestreamURL, ttl) -} - -// livestreamExpiresAt returns when the signed URL should expire. -// -// On the legacy path it is at most livestreamURLExpiry from now and, when a -// program is in progress, at most maxLivestreamURLAgeFromStart from its (clamped) -// start time — the from-start cap exists because manifests larger than that -// exceed the Lambda@Edge size limit. The stream-proxy has no such limit, so on -// the proxy path (useProxy) the URL keeps the full livestreamURLExpiry. -// -// The result is the window handed to the signer as a ttl. On the proxy path the -// minted JWT outlives it (streamtoken.SignLiveURL); on the legacy path it is the -// signature's exact expiry. -func livestreamExpiresAt(start *time.Time, now time.Time, useProxy bool) time.Time { - expiresAt := now.Add(livestreamURLExpiry) - if useProxy { - return expiresAt - } - if start != nil { - if capped := start.Add(maxLivestreamURLAgeFromStart); capped.Before(expiresAt) { - expiresAt = capped - } +// (see appendTimeShiftTags); the proxy forwards them to the upstream manifest. +func (r *Resolver) signLiveManifest(livestreamURL string, ttl time.Duration, provider streamtoken.Provider) (string, time.Time, error) { + u, err := url.Parse(livestreamURL) + if err != nil { + return "", time.Time{}, err } - return expiresAt + return r.StreamURLSigner.SignLiveURL(u.Path, ttl, provider) } // clampStart caps start so it points at most maxLivestreamStartAge before now. @@ -176,11 +120,9 @@ func clampStart(start, now time.Time) time.Time { // // MediaPackage v2 endpoints accept time-shift only as a query parameter — the // path-element form (.../start/