btcpayserver-plugin-builder/PluginBuilder/Program.cs
2026-05-04 18:23:05 +01:00

316 lines
13 KiB
C#

using System.Net.Http.Headers;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Npgsql;
using PluginBuilder.Authentication;
using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic;
using PluginBuilder.DataModels;
using PluginBuilder.HostedServices;
using PluginBuilder.Hubs;
using PluginBuilder.Services;
using PluginBuilder.Util.Extensions;
using Serilog;
using Serilog.Events;
using Serilog.Extensions.Logging;
namespace PluginBuilder;
public class Program
{
public static Task Main(string[] args)
{
Program host = new();
return new Program().Start(args);
}
public async Task Start(string[]? args = null)
{
var app = CreateWebApplication(args);
try
{
await app.RunAsync();
}
finally
{
await Log.CloseAndFlushAsync();
}
}
public WebApplication CreateWebApplication(string[]? args = null)
{
var builder = CreateWebApplicationBuilder(args);
var app = builder.Build();
Configure(app);
return app;
}
public WebApplicationBuilder CreateWebApplicationBuilder(string[]? args = null)
{
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
ConfigureBuilder(builder);
return builder;
}
public WebApplicationBuilder CreateWebApplicationBuilder(WebApplicationOptions options)
{
var builder = WebApplication.CreateBuilder(options);
ConfigureBuilder(builder);
return builder;
}
private void ConfigureBuilder(WebApplicationBuilder builder)
{
builder.Configuration.AddEnvironmentVariables("PB_");
#if DEBUG
builder.Logging.AddFilter(typeof(ProcessRunner).FullName, LogLevel.Trace);
#endif
var verbose = builder.Configuration.GetValue<bool>("verbose");
if (!verbose)
builder.Logging.AddFilter("Events", LogLevel.Warning);
builder.Services.AddHealthChecks().AddCheck<HealthService>("Dependencies");
// Uncomment this to see EF queries
// builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
builder.Logging.AddFilter("Microsoft", LogLevel.Error);
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Information);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
builder.Logging.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
AddServices(builder.Configuration, builder.Services, builder.Environment);
}
public void Configure(WebApplication app)
{
// ForwardedHeaders.All + cleared KnownNetworks/KnownProxies is required because proxy IPs are dynamic
// in Docker (populating KnownProxies would create a circular dependency in docker-compose).
// This means X-Forwarded-Host is trusted from any source. Host header poisoning is mitigated by:
// 1. nginx explicitly setting X-Forwarded-Host (see btcpayserver-plugin-builder-infra/nginx.tmpl)
// 2. PB_ALLOWEDHOSTS env var restricting accepted hostnames via HostFilteringMiddleware
// https://github.com/btcpayserver/btcpayserver-plugin-builder-infra/pull/2
ForwardedHeadersOptions forwardingOptions = new() { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto };
forwardingOptions.KnownNetworks.Clear();
forwardingOptions.KnownProxies.Clear();
forwardingOptions.ForwardedHeaders = ForwardedHeaders.All;
app.UseForwardedHeaders(forwardingOptions);
if (!app.Environment.IsDevelopment())
app.UseHsts();
app.UseStatusCodePagesWithReExecute("/errors/{0}");
app.UseExceptionHandler("/errors/500");
// Capture base URL once on first request for FirstBuildEvents
app.Use(async (ctx, next) =>
{
var fbe = ctx.RequestServices.GetRequiredService<FirstBuildEvent>();
if (ctx.Request.Host.HasValue)
{
var baseUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
fbe.InitBaseUrl(baseUrl);
}
await next();
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
app.MapHub<PluginHub>("hub");
app.MapHub<PluginHub>("/plugins/{pluginSlug}/hub");
app.MapHub<PluginHub>("/plugins/{pluginSlug}/builds/{buildId}/hub");
// no default routes
app.MapControllers();
}
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
{
services.AddControllersWithViews()
.AddRazorRuntimeCompilation()
.AddRazorOptions(options =>
{
options.ViewLocationFormats.Add("/{0}.cshtml");
})
.AddNewtonsoftJson(o => o.SerializerSettings.Formatting = Formatting.Indented)
.AddApplicationPart(typeof(Program).Assembly);
var pbOptions = PluginBuilderOptions.ConfigureDataDirAndDebugLog(configuration, env);
services.AddSingleton(pbOptions);
services.AddDataProtection()
.SetApplicationName("Plugin Builder")
.PersistKeysToFileSystem(new DirectoryInfo(pbOptions.DataDir));
const long maxDebugLogFileSize = 2_000_000;
services.AddLogging(logBuilder =>
{
var debugLogFile = pbOptions.DebugLogFile;
if (string.IsNullOrEmpty(debugLogFile))
return;
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Is(pbOptions.DebugLogLevel ?? LogEventLevel.Information)
.WriteTo.File(debugLogFile,
rollingInterval: RollingInterval.Day,
fileSizeLimitBytes: maxDebugLogFileSize,
rollOnFileSizeLimit: true,
retainedFileCountLimit: pbOptions.LogRetainCount)
.CreateLogger();
logBuilder.AddProvider(new SerilogLoggerProvider(Log.Logger));
});
services.AddHostedService<DatabaseStartupHostedService>();
services.AddHostedService<DockerStartupHostedService>();
services.AddHostedService<AzureStartupHostedService>();
services.AddHostedService<PluginHubHostedService>();
services.AddHostedService<PluginCleanupHostedService>();
services.AddHostedService<UserCleanupHostedService>();
services.AddSingleton<DBConnectionFactory>();
services.AddScoped<TelemetryService>();
services.AddScoped<PluginCleanupRunner>();
services.AddScoped<UserCleanupRunner>();
services.AddSingleton<BuildService>();
services.AddSingleton<ProcessRunner>();
services.AddSingleton<GPGKeyService>();
services.AddSingleton<AzureStorageClient>();
services.AddSingleton<ServerEnvironment>();
services.AddSingleton<EventAggregator>();
services.AddSingleton<HealthService>();
services.AddHttpClient(HttpClientNames.GitHub, client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder");
var token = configuration["GITHUB_TOKEN"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
});
services.AddHttpClient(HttpClientNames.GitLab, client =>
{
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder");
var token = configuration["GITLAB_TOKEN"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Add("PRIVATE-TOKEN", token);
});
services.AddSingleton<IGitHostingProvider, GitHubHostingProvider>();
services.AddSingleton<IGitHostingProvider, GitLabHostingProvider>();
services.AddSingleton<GitHostingProviderFactory>();
services.AddSingleton<ExternalAccountVerificationService>();
services.AddSingleton<EmailService>();
services.AddSingleton<FirstBuildEvent>();
services.AddSingleton<NostrService>();
// shared controller logic
services.AddSingleton<AdminSettingsCache>();
services.AddTransient<UserVerifiedLogic>();
services.AddScoped<ReferrerNavigationService>();
services.AddHttpContextAccessor();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
services.AddScoped<IUrlHelper>(sp =>
{
var actionContext = sp.GetRequiredService<IActionContextAccessor>().ActionContext;
return new UrlHelper(actionContext);
});
services.AddScoped<PluginOwnershipService>();
services.AddScoped<VersionLifecycleService>();
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
code = "429",
message = "Too many requests. Please try again later."
}, cancellationToken);
};
options.AddPolicy(Policies.PublicApiRateLimit, httpContext =>
{
var cache = httpContext.RequestServices.GetRequiredService<AdminSettingsCache>();
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = cache.RateLimitPermitLimit,
Window = TimeSpan.FromSeconds(cache.RateLimitWindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
services.AddOutputCache(options =>
{
options.AddPolicy("PluginsList", p => p
.Expire(TimeSpan.FromSeconds(60))
.SetVaryByQuery("btcpayVersion", "includePreRelease", "includeAllVersions", "searchPluginName")
.Tag(CacheTags.Plugins));
});
var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetRequired("POSTGRES"));
dataSourceBuilder.MapEnum<PluginVisibilityEnum>("plugin_visibility_enum");
var dataSource = dataSourceBuilder.Build();
services.AddDbContext<IdentityDbContext<IdentityUser>>(b =>
{
b.UseNpgsql(dataSource);
});
services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequiredLength = 6;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
options.Password.RequireUppercase = false;
})
.AddDefaultTokenProviders()
.AddEntityFrameworkStores<IdentityDbContext<IdentityUser>>();
services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, opt =>
{
opt.LoginPath = "/login";
opt.AccessDeniedPath = "/errors/403";
opt.LogoutPath = "/logout";
});
services.AddAuthentication()
.AddScheme<PluginBuilderAuthenticationOptions, BasicAuthenticationHandler>(PluginBuilderAuthenticationSchemes.BasicAuth, o => { });
services.AddAuthorization(o =>
{
o.AddPolicy(Policies.OwnPlugin, o => o.AddRequirements(new OwnPluginRequirement()));
});
services.AddScoped<IAuthorizationHandler, PluginBuilderAuthorizationHandler>();
services.AddSignalR();
}
}