Make the Worker validate its service container and align Worker and Gateway hosts

The Worker now registers the Application layer, observability and stream tickets, validates the
container on build and ships a complete appsettings. The Gateway drops underscore fields and uses a
placeholder bucket in its default YARP transform.
This commit is contained in:
2026-09-24 12:40:18 +02:00
parent fcf3093ad8
commit c4cf5af7f1
10 changed files with 92 additions and 52 deletions
@@ -14,8 +14,8 @@ public static partial class MassTransitDependencyInjection
public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddValidatedOptions<MassTransitConfiguration>(configuration); services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>(); services.AddScoped<IEventBus, MassTransitEventBus>();
services.AddMassTransit(options => services.AddMassTransit(options =>
{ {
AddOutbox(options); AddOutbox(options);
@@ -34,6 +34,7 @@ public static partial class MassTransitDependencyInjection
services.AddValidatedOptions<MassTransitConfiguration>(configuration); services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddValidatedOptions<WorkerConfiguration>(configuration); services.AddValidatedOptions<WorkerConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>();
RegisterRoutingSlipBuilders(services); RegisterRoutingSlipBuilders(services);
@@ -38,13 +38,6 @@ public static class ServicesDependencyInjection
return services; return services;
} }
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
return services;
}
public static IServiceCollection AddJobs(this IServiceCollection services) public static IServiceCollection AddJobs(this IServiceCollection services)
{ {
services.AddHangfire((serviceProvider, hangfireConfiguration) => services.AddHangfire((serviceProvider, hangfireConfiguration) =>
@@ -14,27 +14,24 @@ public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
public sealed class TicketValidator : IDisposable public sealed class TicketValidator : IDisposable
{ {
private readonly StreamTicketValidationConfiguration _options; private readonly RSA rsa;
private readonly RSA _rsa; private readonly TokenValidationParameters validationParameters;
private readonly TokenValidationParameters _validationParameters; private readonly JsonWebTokenHandler handler = new();
private readonly JsonWebTokenHandler _handler = new();
public TicketValidator(StreamTicketValidationConfiguration options) public TicketValidator(StreamTicketValidationConfiguration configuration)
{ {
_options = options; rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath));
_rsa = RSA.Create(); validationParameters = new TokenValidationParameters
_rsa.ImportFromPem(File.ReadAllText(_options.PublicKeyPath));
_validationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = _options.Issuer, ValidIssuer = configuration.Issuer,
ValidateAudience = true, ValidateAudience = true,
ValidAudience = _options.Audience, ValidAudience = configuration.Audience,
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(_rsa), IssuerSigningKey = new RsaSecurityKey(rsa),
ValidAlgorithms = [SecurityAlgorithms.RsaSha256], ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ClockSkew = TimeSpan.FromSeconds(30), ClockSkew = TimeSpan.FromSeconds(30),
}; };
@@ -45,7 +42,7 @@ public sealed class TicketValidator : IDisposable
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return TicketValidationResult.Invalid; return TicketValidationResult.Invalid;
var result = await _handler.ValidateTokenAsync(token, _validationParameters); var result = await handler.ValidateTokenAsync(token, validationParameters);
if (!result.IsValid) if (!result.IsValid)
return TicketValidationResult.Invalid; return TicketValidationResult.Invalid;
@@ -59,5 +56,5 @@ public sealed class TicketValidator : IDisposable
return new TicketValidationResult(prefix, maxBytes); return new TicketValidationResult(prefix, maxBytes);
} }
public void Dispose() => _rsa.Dispose(); public void Dispose() => rsa.Dispose();
} }
@@ -3,7 +3,9 @@ using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Configuration;
using Musify.StreamingGateway.Middleware; using Musify.StreamingGateway.Middleware;
var builder = WebApplication.CreateBuilder(); const string CorsPolicy = "media-clients";
var builder = WebApplication.CreateBuilder(args);
builder.Services builder.Services
.AddOptions<StreamTicketValidationConfiguration>() .AddOptions<StreamTicketValidationConfiguration>()
@@ -16,9 +18,8 @@ builder.Services.AddSingleton(serviceProvider =>
builder.Services.AddSingleton<TicketValidator>(); builder.Services.AddSingleton<TicketValidator>();
const string corsPolicy = "media-clients";
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []; var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy =>
{ {
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
policy.AllowAnyOrigin(); policy.AllowAnyOrigin();
@@ -37,9 +38,9 @@ builder.Services
var app = builder.Build(); var app = builder.Build();
app.MapGet("/health", () => Results.Ok("healthy")) app.MapGet("/health", () => Results.Ok("healthy"))
.WithSummary("Health Check Endpoint"); .WithSummary("Health check endpoint");
app.UseCors(corsPolicy); app.UseCors(CorsPolicy);
app.UseMiddleware<TicketValidationMiddleware>(); app.UseMiddleware<TicketValidationMiddleware>();
app.MapReverseProxy(); app.MapReverseProxy();
@@ -1,5 +1,14 @@
{ {
"StreamTicket": { "StreamTicket": {
"PublicKeyPath": "../../../deploy/keys/stream_public.pem" "PublicKeyPath": "../../../deploy/keys/stream_public.pem"
},
"ReverseProxy": {
"Routes": {
"media": {
"Transforms": [
{ "PathPattern": "/buckets/webapi-storage/{**rest}" }
]
}
}
} }
} }
@@ -26,7 +26,7 @@
"ClusterId": "seaweed-filer", "ClusterId": "seaweed-filer",
"Match": { "Path": "/media/{**rest}" }, "Match": { "Path": "/media/{**rest}" },
"Transforms": [ "Transforms": [
{ "PathPattern": "/buckets/webapi-storage/{**rest}" } { "PathPattern": "/buckets/CHANGE_ME/{**rest}" }
] ]
} }
}, },
+14 -19
View File
@@ -1,32 +1,27 @@
using Musify.Application.Configuration; using Musify.Application;
using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Observability;
using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
var builder = Host.CreateApplicationBuilder(); var builder = Host.CreateApplicationBuilder(args);
builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
{ {
ValidateOnBuild = false, ValidateOnBuild = true,
ValidateScopes = true ValidateScopes = true
})); }));
var configuration = builder.Configuration; builder.Services
.AddApplication(builder.Configuration)
var services = builder.Services; .AddObservability(builder.Configuration)
.AddDatabase(builder.Configuration)
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration); .AddStorageService(builder.Configuration)
services.AddValidatedOptions<PlayListConfiguration>(configuration); .AddAudioTranscoder(builder.Configuration)
services.AddValidatedOptions<TrackConfiguration>(configuration); .AddStreamTicketService(builder.Configuration)
services.AddValidatedOptions<MixConfiguration>(configuration); .AddPictureService()
services.AddStorageService(configuration); .AddMassTransitConsumers(builder.Configuration)
services.AddAudioTranscoder(configuration); .AddJobs();
services.AddPictureService();
services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration);
services.AddUploadIntentConfiguration(configuration);
services.AddMediator();
services.AddJobs();
var host = builder.Build(); var host = builder.Build();
@@ -26,10 +26,11 @@
"ApplicationStorage": { "ApplicationStorage": {
"Bucket": "webapi-storage" "Bucket": "webapi-storage"
}, },
"AudioTranscoder": { "StreamGateway": {
"Routes": { "PublicBaseUrl": "http://localhost:8081"
"WorkingDirectory": "../../../deploy/.tmp" },
} "StreamTicket": {
"PrivateKeyPath": "../../../deploy/keys/stream_private.pem"
}, },
"Workers": { "Workers": {
"Routes": { "Routes": {
@@ -26,11 +26,27 @@
"ApplicationStorage": { "ApplicationStorage": {
"Bucket": "" "Bucket": ""
}, },
"StreamGateway": {
"PublicBaseUrl": "",
"AudioFileName": "audio.m4a"
},
"StreamTicket": {
"PrivateKeyPath": "",
"Audience": "media-gateway",
"Issuer": "musify-webapi",
"TicketTtlSeconds": 3600
},
"Playback": {
"AllowAnonymousListening": false,
"AnonymousFragmentSeconds": 0,
"EstimatedAudioBytesPerSecond": 16000
},
"Mix": { "Mix": {
"ItemsPerMix": 12 "ItemsPerMix": 12
}, },
"PlayList": { "PlayList": {
"Routes": { "Routes": {
"UploadsFolder": "uploads",
"ParentFolder": "PlayLists", "ParentFolder": "PlayLists",
"OriginalPicturesFolder": "OriginalPictures", "OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures", "SmallPicturesFolder": "SmallPictures",
@@ -46,8 +62,27 @@
"LargeHeight": 512 "LargeHeight": 512
} }
}, },
"Album": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolder": "Albums",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures"
},
"PicturesSizes": {
"SmallWidth": 128,
"SmallHeight": 128,
"MediumWidth": 256,
"MediumHeight": 256,
"LargeWidth": 512,
"LargeHeight": 512
}
},
"Track": { "Track": {
"Routes": { "Routes": {
"UploadsFolder": "uploads",
"ParentFolder": "Tracks", "ParentFolder": "Tracks",
"OriginalPicturesFolder": "OriginalPictures", "OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures", "SmallPicturesFolder": "SmallPictures",
+8
View File
@@ -340,10 +340,18 @@ services:
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
keys-init:
condition: service_completed_successfully
environment: environment:
<<: *backend-env <<: *backend-env
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
# The Application handlers need these to build their dependencies; the
# Worker never issues stream tickets, so the key is never read.
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
Workers__Routes__TemporaryFilesDirectory: /tmp/musify Workers__Routes__TemporaryFilesDirectory: /tmp/musify
volumes:
- ./keys:/keys:ro
restart: unless-stopped restart: unless-stopped
# YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and