using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using SQLInsight.Hub; using SQLInsight.Hub.Api; using SQLInsight.Hub.Data; using SQLInsight.Hub.Maintenance; using SQLInsight.Hub.Portal; namespace SQLInsight.Hub; /// /// SQLInsight Hub: receives findings from paying customers' agents into PostgreSQL, and serves the vendor portal. /// public static class HubProgram { public static async Task Main(string[] args) { if (args.FirstOrDefault() == "add-staff") { await AddStaffAsync(args).ConfigureAwait(false); return; } if (args.FirstOrDefault() == "delete-customer") { await DeleteCustomerAsync(args).ConfigureAwait(false); return; } var app = Build(args); await MigrateAsync(app.Services).ConfigureAwait(false); await app.RunAsync().ConfigureAwait(false); } public static WebApplication Build(string[] args) { var builder = WebApplication.CreateBuilder(args); builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Hub"); builder.Services.AddSystemd(); builder.Services.Configure(builder.Configuration.GetSection(HubServerOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); var connectionString = builder.Configuration.GetConnectionString("Hub") ?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL)."); builder.Services.AddDbContext(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention()); var settings = builder.Configuration.GetSection(HubServerOptions.SectionName).Get() ?? new HubServerOptions(); if (settings.DataProtectionKeysPath is { Length: > 0 } keys) { builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keys)).SetApplicationName("SQLInsight.Hub"); } builder.Services.Configure(o => { o.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto; foreach (var proxy in settings.KnownProxies) { o.KnownProxies.Add(System.Net.IPAddress.Parse(proxy)); } }); builder.Services.AddScoped(); builder.Services.AddSingleton(settings.Insights); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddRazorComponents(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All)); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { o.LoginPath = "/portal/login"; o.Cookie.Name = "SQLInsightHub"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; o.ExpireTimeSpan = TimeSpan.FromHours(8); o.SlidingExpiration = true; }); builder.Services.AddAuthorization(); var loginAttempts = settings.LoginAttemptsPerMinute; builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; o.AddPolicy(PortalEndpoints.LoginRateLimit, http => RateLimitPartition.GetFixedWindowLimiter( http.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = loginAttempts, Window = TimeSpan.FromMinutes(1) })); }); var app = builder.Build(); app.UseForwardedHeaders(); if (!app.Environment.IsDevelopment()) { app.UseHsts(); } app.Use(async (context, next) => { context.Response.Headers.XContentTypeOptions = "nosniff"; context.Response.Headers.XFrameOptions = "DENY"; context.Response.Headers["Referrer-Policy"] = "no-referrer"; context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'"; await next(); }); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.UseAntiforgery(); app.MapStaticAssets(); if (settings.UpdateFeedPath is { Length: > 0 } feed) { // Everything in the feed is signed or pinned by hash in a signed manifest; the hub only has to serve it. var types = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); types.Mappings[".sqli"] = "text/plain"; app.UseStaticFiles(new StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(Path.GetFullPath(feed)), RequestPath = "/updates", ContentTypeProvider = types, ServeUnknownFileTypes = false, }); } app.MapGet("/", () => Results.Redirect("/portal")); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); FindingsEndpoint.Map(app); CasesEndpoint.Map(app); SQLInsight.Hub.Insights.InsightsEndpoint.Map(app); PortalEndpoints.Map(app); app.MapRazorComponents(); return app; } /// SQLInsight.Hub add-staff <brugernavn> <navn>, adgangskode fra SQLINSIGHT_STAFF_PASSWORD eller stdin. private static async Task AddStaffAsync(string[] args) { var app = Build(args.Skip(3).ToArray()); await MigrateAsync(app.Services).ConfigureAwait(false); var password = Environment.GetEnvironmentVariable("SQLINSIGHT_STAFF_PASSWORD") ?? Console.ReadLine() ?? string.Empty; await using var scope = app.Services.CreateAsyncScope(); var user = await scope.ServiceProvider.GetRequiredService().CreateAsync(args[1], args[2], password).ConfigureAwait(false); Console.WriteLine($"Oprettet: {user.UserName}"); } /// SQLInsight.Hub delete-customer <kundenavn> --confirm: sletter alle kundens data og spærrer licenserne. private static async Task DeleteCustomerAsync(string[] args) { if (args.Length < 2 || !args.Contains("--confirm")) { Console.Error.WriteLine("Brug: SQLInsight.Hub delete-customer \"\" --confirm"); Environment.ExitCode = 1; return; } var app = Build(args.Skip(2).Where(a => a != "--confirm").ToArray()); await using var scope = app.Services.CreateAsyncScope(); var deleted = await scope.ServiceProvider.GetRequiredService().DeleteCustomerAsync(args[1]).ConfigureAwait(false); Console.WriteLine(deleted ? $"Alle data for {args[1]} er slettet, og licenserne er spærret." : $"Kunden {args[1]} findes ikke."); Environment.ExitCode = deleted ? 0 : 2; } public static async Task MigrateAsync(IServiceProvider services) { ArgumentNullException.ThrowIfNull(services); await using var scope = services.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync().ConfigureAwait(false); } }