- Case numbers SUP-000001, SUP-000002 … per customer, unique per customer (new index; a migration step renames any old duplicates first). The portal suggests the next number on the finding page and under the new "Ny sag" on the customer page; an empty field gets the next number, a typed one that is taken is refused. Concurrent creates retry. - SupportCase gains Source (Staff/Customer), description, contact name, e-mail, phone, the customer's Windows user and ClientRequestId. - POST/GET /api/v1/cases for the customer's agent (license with priority support): creates a case idempotently on ClientRequestId, links the finding it came from, lists the customer's cases incl. staff-created. Customer/license creation is shared with the findings endpoint. - Optional mail to support for customer cases (MailKit; prefers AUTH LOGIN as Microsoft 365 needs). A failed mail is logged, never fails the call. deploy-hub.yml writes optional SUPPORT_SMTP_* secrets to .env. - Supportrapport: created, closed, open now, average/median resolution time, oldest open case and customer-created share per customer for a period (default 90 days), plus the cases; CSV for Danish Excel. - Cases page: newest first, customer cases marked, contact/description. - Customer page: findings can be filtered to one instance (?instans=id, ignored for another customer's instance); active findings per instance by severity. - dotnet-ef as a local tool (dotnet-tools.json) for the migration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
171 lines
7.9 KiB
C#
171 lines
7.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// SQLInsight Hub: receives findings from paying customers' agents into PostgreSQL, and serves the vendor portal.
|
|
/// </summary>
|
|
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<HubServerOptions>(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<HubDbContext>(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
|
|
|
|
var settings = builder.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>() ?? new HubServerOptions();
|
|
if (settings.DataProtectionKeysPath is { Length: > 0 } keys)
|
|
{
|
|
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keys)).SetApplicationName("SQLInsight.Hub");
|
|
}
|
|
|
|
builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(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<HubMaintenance>();
|
|
builder.Services.AddSingleton(settings.Insights);
|
|
builder.Services.AddScoped<SQLInsight.Hub.Insights.InsightCalculator>();
|
|
builder.Services.AddHostedService<HubRetentionService>();
|
|
builder.Services.AddScoped<StaffAccounts>();
|
|
builder.Services.AddScoped<PortalQueries>();
|
|
builder.Services.AddSingleton<SQLInsight.Hub.Support.SupportNotifier>();
|
|
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<PortalApp>();
|
|
return app;
|
|
}
|
|
|
|
/// <summary><c>SQLInsight.Hub add-staff <brugernavn> <navn></c>, adgangskode fra SQLINSIGHT_STAFF_PASSWORD eller stdin.</summary>
|
|
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<StaffAccounts>().CreateAsync(args[1], args[2], password).ConfigureAwait(false);
|
|
Console.WriteLine($"Oprettet: {user.UserName}");
|
|
}
|
|
|
|
/// <summary><c>SQLInsight.Hub delete-customer <kundenavn> --confirm</c>: sletter alle kundens data og spærrer licenserne.</summary>
|
|
private static async Task DeleteCustomerAsync(string[] args)
|
|
{
|
|
if (args.Length < 2 || !args.Contains("--confirm"))
|
|
{
|
|
Console.Error.WriteLine("Brug: SQLInsight.Hub delete-customer \"<kundenavn>\" --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<HubMaintenance>().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<HubDbContext>().Database.MigrateAsync().ConfigureAwait(false);
|
|
}
|
|
}
|