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);
}
}