using System.Text; using System.Text.Json.Serialization; using AspNetCoreRateLimit; using HealthChecks.UI.Client; using Managing.Api.Authorization; using Managing.Api.Filters; using Managing.Api.HealthChecks; using Managing.Application.Abstractions.Services; using Managing.Application.Hubs; using Managing.Bootstrap; using Managing.Common; using Managing.Core.Middleawares; using Managing.Infrastructure.Databases.InfluxDb.Models; using Managing.Infrastructure.Databases.PostgreSql; using Managing.Infrastructure.Databases.PostgreSql.Configurations; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using NSwag; using NSwag.Generation.Processors.Security; using Serilog; using Serilog.Events; using Serilog.Sinks.Elasticsearch; using OpenApiSecurityRequirement = Microsoft.OpenApi.Models.OpenApiSecurityRequirement; using OpenApiSecurityScheme = NSwag.OpenApiSecurityScheme; using DotNetEnv; // Optionally load .env file if it exists (primarily for Vibe Kanban worktrees) // This is optional - if no .env file exists, the app will use system env vars and appsettings.json // This must happen before WebApplication.CreateBuilder to ensure env vars are available var enableEnvFile = Environment.GetEnvironmentVariable("ENABLE_ENV_FILE") != "false"; // Can be disabled via env var if (enableEnvFile) { // Try multiple locations: current directory, project root, and solution root var envFilePaths = new[] { Path.Combine(Directory.GetCurrentDirectory(), ".env"), // Current working directory Path.Combine(AppContext.BaseDirectory, ".env"), // Executable directory Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".env")), // Project root (from bin/Debug/net8.0) Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".env")), // Current directory (absolute) }; string? loadedEnvPath = null; foreach (var envPath in envFilePaths) { if (File.Exists(envPath)) { try { Env.Load(envPath); loadedEnvPath = envPath; Console.WriteLine($"✅ Loaded .env file from: {envPath} (optional - for Vibe Kanban worktrees)"); break; } catch (Exception ex) { Console.WriteLine($"⚠️ Failed to load .env file from {envPath}: {ex.Message}"); } } } // Silently continue if no .env file found - this is expected in normal operation // .env file is only needed for Vibe Kanban worktrees } // Builder var builder = WebApplication.CreateBuilder(args); builder.Configuration.SetBasePath(AppContext.BaseDirectory); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json") .AddJsonFile($"config.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); builder.Configuration.AddEnvironmentVariables(); builder.Configuration.AddUserSecrets(); // Initialize Sentry conditionally based on DSN availability var sentryDsn = builder.Configuration["Sentry:Dsn"]; if (!string.IsNullOrWhiteSpace(sentryDsn)) { SentrySdk.Init(options => { // A Sentry Data Source Name (DSN) is required. // See https://docs.sentry.io/concepts/key-terms/dsn-explainer/ // You can set it in the SENTRY_DSN environment variable, or you can set it in code here. options.Dsn = sentryDsn; // When debug is enabled, the Sentry client will emit detailed debugging information to the console. // This might be helpful, or might interfere with the normal operation of your application. // We enable it here for demonstration purposes when first trying Sentry. // You shouldn't do this in your applications unless you're troubleshooting issues with Sentry. options.Debug = false; // Adds request URL and headers, IP and name for users, etc. options.SendDefaultPii = true; // This option is recommended. It enables Sentry's "Release Health" feature. options.AutoSessionTracking = true; // Enabling this option is recommended for client applications only. It ensures all threads use the same global scope. options.IsGlobalModeEnabled = false; // Example sample rate for your transactions: captures 10% of transactions options.TracesSampleRate = 0.1; options.Environment = builder.Environment.EnvironmentName; }); } builder.Services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy(), ["api"]); var postgreSqlConnectionString = builder.Configuration.GetSection(Constants.Databases.PostgreSql)["ConnectionString"]; var influxUrl = builder.Configuration.GetSection(Constants.Databases.InfluxDb)["Url"]; var web3ProxyUrl = builder.Configuration.GetSection("Web3Proxy")["BaseUrl"]; var kaigenBaseUrl = builder.Configuration.GetSection("Kaigen")["BaseUrl"]; // Add HTTP client for Web3Proxy health check with detailed response builder.Services.AddHttpClient("Web3ProxyHealthCheck") .ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(15); }); // Add HTTP client for Kaigen API health check builder.Services.AddHttpClient("KaigenHealthCheck") .ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(15); }); // Add HTTP client for GMX API health check builder.Services.AddHttpClient("GmxHealthCheck") .ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); // Register Web3ProxyHealthCheck with the web3ProxyUrl builder.Services.AddSingleton(sp => new Web3ProxyHealthCheck(sp.GetRequiredService(), web3ProxyUrl)); // Register KaigenHealthCheck with the kaigenBaseUrl builder.Services.AddSingleton(sp => new KaigenHealthCheck(sp.GetRequiredService(), kaigenBaseUrl)); // Add SQL Loop Detection Service with Sentry integration // Configure SQL monitoring settings builder.Services.Configure(builder.Configuration.GetSection("SqlMonitoring")); // Register SQL monitoring services builder.Services.AddSingleton(); // Add PostgreSQL DbContext with improved concurrency and connection management builder.Services.AddDbContext((serviceProvider, options) => { options.UseNpgsql(postgreSqlConnectionString, npgsqlOptions => { // Configure connection pooling and timeout settings for better concurrency npgsqlOptions.CommandTimeout(60); // Increase command timeout for complex queries npgsqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorCodesToAdd: null); }); // Enable detailed errors in development if (builder.Environment.IsDevelopment()) { options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); options.EnableThreadSafetyChecks(); // Enable thread safety checks in development } // Configure query tracking behavior for better performance options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); // Default to no tracking for better performance // Enable service provider caching for better performance options.EnableServiceProviderCaching(); // Disable SQL query logging to reduce log volume (only errors will be logged via standard logging) // SQL monitoring is handled by SentrySqlMonitoringService which respects LogSlowQueriesOnly setting }, ServiceLifetime.Scoped); // Explicitly specify scoped lifetime for proper request isolation // Add specific health checks for databases and other services builder.Services.AddHealthChecks() .AddNpgSql(postgreSqlConnectionString, name: "postgresql", tags: ["database"]) .AddUrlGroup(new Uri($"{influxUrl}/health"), name: "influxdb", tags: ["database"]) .AddCheck("web3proxy", tags: ["api", "external"]) .AddCheck("candle-data", tags: ["database", "candles"]) .AddCheck("candle-data-detailed", tags: ["database", "candles-detailed"]) .AddCheck("gmx-connectivity", tags: ["api", "external"]) .AddCheck("kaigen-api", tags: ["api", "external"]) .AddCheck("orleans-cluster", tags: ["orleans", "cluster"]); builder.Host.UseSerilog((hostBuilder, loggerConfiguration) => { var envName = builder.Environment.EnvironmentName.ToLower().Replace(".", "-"); var indexFormat = $"managing-{envName}-" + "{0:yyyy.MM.dd}"; var yourTemplateName = "dotnetlogs"; var es = new ElasticsearchSinkOptions(new Uri(hostBuilder.Configuration["ElasticConfiguration:Uri"])) { IndexFormat = indexFormat.ToLower(), AutoRegisterTemplate = true, OverwriteTemplate = true, TemplateName = yourTemplateName, AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7, TypeName = null, BatchAction = ElasticOpType.Create, MinimumLogEventLevel = LogEventLevel.Information, DetectElasticsearchVersion = true, RegisterTemplateFailure = RegisterTemplateRecovery.IndexAnyway, }; loggerConfiguration .MinimumLevel .Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning) // Filter out EF Core SQL query logs .WriteTo.Console() .WriteTo.Elasticsearch(es); }); builder.Services.AddOptions(); builder.Services.Configure(builder.Configuration.GetSection(Constants.Databases.PostgreSql)); builder.Services.Configure(builder.Configuration.GetSection(Constants.Databases.InfluxDb)); // Configure Request Size Limits (protect against DoS attacks) builder.Services.Configure(options => { options.MultipartBodyLengthLimit = 10485760; // 10MB options.ValueLengthLimit = 10485760; // 10MB options.MultipartBoundaryLengthLimit = 256; options.KeyLengthLimit = 2048; }); builder.Services.Configure(options => { options.Limits.MaxRequestBodySize = 10485760; // 10MB options.Limits.MaxRequestHeadersTotalSize = 32768; // 32KB options.Limits.MaxConcurrentConnections = 100; options.Limits.MaxConcurrentUpgradedConnections = 100; options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); }); // Configure Rate Limiting builder.Services.AddMemoryCache(); builder.Services.Configure(builder.Configuration.GetSection("IpRateLimiting")); builder.Services.Configure(builder.Configuration.GetSection("IpRateLimitPolicies")); builder.Services.AddInMemoryRateLimiting(); builder.Services.AddSingleton(); builder.Services.AddControllers().AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); // Validate JWT secret configuration var jwtSecret = builder.Configuration["Jwt:Secret"]; if (string.IsNullOrWhiteSpace(jwtSecret)) { throw new InvalidOperationException( "JWT secret is not configured. Please set 'Jwt:Secret' environment variable."); } if (jwtSecret.Length < 32) { throw new InvalidOperationException( $"JWT secret must be at least 32 characters long. Current length: {jwtSecret.Length}. " + "This is a security requirement for production environments."); } // Get issuer and audience configuration var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"]; var validAudiences = builder.Configuration.GetSection("Authentication:Schemes:Bearer:ValidAudiences") .Get() ?? Array.Empty(); // Determine if validation should be enabled (enable in production, allow override via config) var enableIssuerValidation = builder.Configuration.GetValue("Jwt:ValidateIssuer", !builder.Environment.IsDevelopment()); var enableAudienceValidation = builder.Configuration.GetValue("Jwt:ValidateAudience", !builder.Environment.IsDevelopment()); // Configure clock skew (tolerance for time differences between servers) var clockSkewSeconds = builder.Configuration.GetValue("Jwt:ClockSkewSeconds", 0); var clockSkew = TimeSpan.FromSeconds(clockSkewSeconds); builder.Services .AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(o => { o.SaveToken = true; o.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = validIssuer, ValidAudiences = validAudiences.Length > 0 ? validAudiences : null, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), ValidateIssuerSigningKey = true, ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer), ValidateAudience = enableAudienceValidation && validAudiences.Length > 0, ValidateLifetime = true, // Explicitly validate token expiration ClockSkew = clockSkew, // Configure clock skew tolerance RequireExpirationTime = true, // Ensure tokens have expiration RequireSignedTokens = true // Ensure tokens are signed }; o.Events = new JwtBearerEvents { OnMessageReceived = context => { // Skip token extraction for anonymous endpoints to avoid validation errors var path = context.Request.Path.Value?.ToLower(); if (path != null && (path.EndsWith("/create-token") || path.EndsWith("/authenticate"))) { // Clear any token to prevent validation on anonymous endpoints context.Token = null; return Task.CompletedTask; } // Handle tokens sent without "Bearer " prefix for authenticated endpoints // The standard middleware expects "Bearer " but some clients send just the token if (string.IsNullOrEmpty(context.Token)) { var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); if (!string.IsNullOrEmpty(authHeader)) { // If header doesn't start with "Bearer ", treat the entire value as the token if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { context.Token = authHeader; } // Otherwise, let the default middleware extract it (it will strip "Bearer " automatically) } } // If you want to get the token from a custom header or query string // var accessToken = context.Request.Query["access_token"]; // if (!string.IsNullOrEmpty(accessToken) && // context.HttpContext.Request.Path.StartsWithSegments("/hub")) // { // context.Token = accessToken; // } return Task.CompletedTask; }, OnAuthenticationFailed = context => { var logger = context.HttpContext.RequestServices .GetService>(); // Check if the endpoint allows anonymous access var endpoint = context.HttpContext.GetEndpoint(); var allowAnonymous = endpoint?.Metadata.GetMetadata() != null; // For anonymous endpoints with malformed tokens, skip authentication instead of failing if (allowAnonymous && context.Exception is SecurityTokenMalformedException) { logger?.LogDebug("Skipping malformed token validation for anonymous endpoint: {Path}", context.Request.Path); context.NoResult(); // Skip authentication, don't fail return Task.CompletedTask; } if (context.Exception is SecurityTokenExpiredException) { context.Response.Headers["Token-Expired"] = "true"; logger?.LogWarning("JWT token expired for request: {Path}", context.Request.Path); } else { logger?.LogError(context.Exception, "JWT authentication failed for request: {Path}", context.Request.Path); } return Task.CompletedTask; }, // --- IMPORTANT: Attach User to Context Here --- OnTokenValidated = async context => { var logger = context.HttpContext.RequestServices .GetService>(); try { var userService = context.HttpContext.RequestServices .GetRequiredService(); // JWT token contains 'address' claim (not NameIdentifier) var address = context.Principal.FindFirst("address")?.Value; if (!string.IsNullOrEmpty(address)) { // Fetch the full user object from your service var user = await userService.GetUserByAddressAsync(address); if (user != null) { // Attach the user object to HttpContext.Items context.HttpContext.Items["User"] = user; logger?.LogDebug("User {Address} authenticated successfully", address); } else { logger?.LogWarning( "JWT token validated but user not found for address: {Address}", address); context.Fail("User not found"); } } else { logger?.LogWarning("JWT token validated but 'address' claim not found"); context.Fail("Invalid token: missing address claim"); } } catch (Exception ex) { logger?.LogError(ex, "Error during JWT token validation - user lookup failed"); context.Fail("Authentication failed: user lookup error"); } await Task.CompletedTask; } // --- END IMPORTANT --- }; }); // Configure CORS from configuration (appsettings.json) var allowedCorsOrigins = builder.Configuration .GetSection("Cors:AllowedOrigins") .Get() ?? Array.Empty(); // Configure CORS with improved security builder.Services.AddCors(options => { options.AddPolicy("CorsPolicy", policy => { if (allowedCorsOrigins.Length > 0) { policy .WithOrigins(allowedCorsOrigins) .WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .WithHeaders("Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID") .WithExposedHeaders("Token-Expired", "X-Correlation-ID") .AllowCredentials() .SetPreflightMaxAge(TimeSpan.FromHours(24)); } else { // Fallback for development if no origins configured policy .AllowAnyMethod() .AllowAnyHeader() .SetIsOriginAllowed(_ => true); } }); }); builder.Services.AddSignalR().AddJsonProtocol(); builder.Services.AddScoped(); builder.Services.RegisterApiDependencies(builder.Configuration); // Orleans is always configured, but grains can be controlled builder.Host.ConfigureOrleans(builder.Configuration, builder.Environment.IsProduction()); builder.Services.AddHostedServices(); var enableSwagger = builder.Configuration.GetValue("EnableSwagger", builder.Environment.IsDevelopment()); if (enableSwagger) { builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApiDocument(document => { document.AddSecurity("JWT", Enumerable.Empty(), new OpenApiSecurityScheme { Type = OpenApiSecuritySchemeType.ApiKey, Name = "Authorization", In = OpenApiSecurityApiKeyLocation.Header, Description = "Type into the textbox: Bearer {your JWT token}." }); document.OperationProcessors.Add( new AspNetCoreOperationSecurityScopeProcessor("JWT")); }); builder.Services.AddSwaggerGen(options => { options.SchemaFilter(); options.AddSecurityDefinition("Bearer,", new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Description = "Please insert your JWT Token into field : Bearer {your_token}", Name = "Authorization", Type = SecuritySchemeType.Http, In = ParameterLocation.Header, Scheme = "Bearer", BearerFormat = "JWT" }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, new string[] { } } }); }); } builder.WebHost.SetupDiscordBot(); // App var app = builder.Build(); app.UseSerilogRequestLogging(); // Rate Limiting - must be before authentication/authorization app.UseIpRateLimiting(); // Security Headers Middleware - add early in pipeline app.Use(async (context, next) => { // Security headers for all responses context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("X-Frame-Options", "DENY"); context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); // Content Security Policy - only for non-Swagger endpoints if (!context.Request.Path.StartsWithSegments("/swagger") && !context.Request.Path.StartsWithSegments("/health") && !context.Request.Path.StartsWithSegments("/alive")) { context.Response.Headers.Append("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"); } // Remove server header (optional - Kestrel can be configured separately) context.Response.Headers.Remove("Server"); await next(); }); // HSTS - HTTP Strict Transport Security (production only) if (!app.Environment.IsDevelopment()) { app.UseHsts(); } if (enableSwagger) { app.UseOpenApi(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "Managing API v1"); c.RoutePrefix = string.Empty; }); } app.UseCors("CorsPolicy"); // Add Sentry diagnostics middleware (now using shared version) app.UseSentryDiagnostics(); // Using shared GlobalErrorHandlingMiddleware from core project app.UseMiddleware(); app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapHub("/bothub"); endpoints.MapHub("/backtesthub"); endpoints.MapHealthChecks("/health", new HealthCheckOptions { Predicate = r => !r.Tags.Contains("candles-detailed"), ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }); endpoints.MapHealthChecks("/health-candles", new HealthCheckOptions { Predicate = r => r.Tags.Contains("candles-detailed"), ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }); endpoints.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live"), ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }); }); // Conditionally run the application based on deployment mode var deploymentMode = builder.Configuration.GetValue("DeploymentMode", false); if (!deploymentMode) { Console.WriteLine("Application starting in normal mode..."); app.Run(); } else { Console.WriteLine("Application configured for deployment mode - skipping app.Run()"); Console.WriteLine("All services have been configured and the application is ready for deployment."); }