using System.Data.SqlClient; using System.Net.WebSockets; using ElmahCore; using ElmahCore.Mvc; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Ngino.Protocol; using Ngino.Server; using Ngino.Server.Data; using Ngino.Server.Models; var builder = WebApplication.CreateBuilder(args); var settings = ServerSettings.FromConfiguration(builder.Configuration); builder.Services.AddSingleton(settings); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddElmah().Configure( options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection")); if (settings.Keycloak.IsConfigured) { builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(options => { options.Cookie.Name = "Ngino.Admin"; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.LoginPath = "/admin/login"; options.LogoutPath = "/admin/logout"; }) .AddOpenIdConnect(options => { options.Authority = settings.Keycloak.Authority; options.ClientId = settings.Keycloak.ClientId; options.ClientSecret = settings.Keycloak.ClientSecret; options.RequireHttpsMetadata = settings.Keycloak.RequireHttpsMetadata; options.ResponseType = OpenIdConnectResponseType.Code; options.ResponseMode = OpenIdConnectResponseMode.Query; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.CorrelationCookie.SameSite = SameSiteMode.Lax; options.CorrelationCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.NonceCookie.SameSite = SameSiteMode.Lax; options.NonceCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("email"); options.Events = new OpenIdConnectEvents { OnRemoteFailure = context => { var errorLog = context.HttpContext.RequestServices.GetService(); if (context.Failure is not null) { errorLog?.Log(new Error(context.Failure)); } context.HandleResponse(); context.Response.Redirect("/admin/auth-error"); return Task.CompletedTask; } }; }); } if (!settings.Keycloak.IsConfigured) { var identityDbPath = Path.Combine(AppContext.BaseDirectory, "App_Data", "identity.sqlite"); var identityConnectionString = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = identityDbPath, Mode = Microsoft.Data.Sqlite.SqliteOpenMode.ReadWriteCreate }.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(identityConnectionString)); builder.Services .AddIdentity(options => { options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 8; options.User.RequireUniqueEmail = true; options.SignIn.RequireConfirmedAccount = false; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); builder.Services.ConfigureApplicationCookie(options => { options.Cookie.Name = "Ngino.Admin"; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None; options.LoginPath = "/admin/login"; options.LogoutPath = "/admin/logout"; options.AccessDeniedPath = "/admin/login"; }); } builder.Services.AddAntiforgery(); builder.Services.AddAuthorization(); builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { if (settings.Cors.AllowedOrigins.Contains("*")) { policy.AllowAnyOrigin(); } else { policy.WithOrigins(settings.Cors.AllowedOrigins); } if (settings.Cors.AllowedMethods.Contains("*")) { policy.AllowAnyMethod(); } else { policy.WithMethods(settings.Cors.AllowedMethods); } if (settings.Cors.AllowedHeaders.Contains("*")) { policy.AllowAnyHeader(); } else { policy.WithHeaders(settings.Cors.AllowedHeaders); } if (settings.Cors.AllowCredentials) { policy.AllowCredentials(); } }); }); var app = builder.Build(); var managementStore = app.Services.GetRequiredService(); var tunnelHub = app.Services.GetRequiredService(); managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id)); app.UseCors(); if (settings.Keycloak.IsConfigured) { app.UseAuthentication(); app.UseAuthorization(); } if (!settings.Keycloak.IsConfigured) { using var scope = app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.EnsureCreated(); app.UseAuthentication(); app.UseAuthorization(); } app.UseAntiforgery(); app.UseElmah(); app.Use(async (context, next) => { context.Response.Headers.XFrameOptions = "DENY"; context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'"; await next(); }); var rateLimiter = app.Services.GetRequiredService(); app.Use(async (context, next) => { if (HttpMethods.IsOptions(context.Request.Method)) { await next(); return; } var ip = AuthRateLimiter.GetClientIp(context.Request); var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip); if (!allowed) { context.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString(); context.Response.ContentType = "application/json"; var seconds = (int)retryAfter!.Value.TotalSeconds; string retryMessage; if (seconds >= 3600) { var hours = seconds / 3600; retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}."; } else if (seconds >= 60) { var minutes = seconds / 60; retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}."; } else { retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}."; } await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted); return; } context.Response.OnStarting(() => { if (context.Response.StatusCode is StatusCodes.Status401Unauthorized) { rateLimiter.RecordFailure(ip, context.Request.Path); } else if (context.Response.StatusCode is StatusCodes.Status302Found && context.Request.Path.StartsWithSegments("/api/admin")) { var location = context.Response.Headers.Location.FirstOrDefault(); if (location is not null && location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase)) { rateLimiter.RecordFailure(ip, context.Request.Path); } else if (location is not null && location.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)) { rateLimiter.RecordSuccess(ip); } } else if (context.Response.StatusCode is >= 200 and < 300 && context.Request.Path.StartsWithSegments("/api/admin")) { rateLimiter.RecordSuccess(ip); } return Task.CompletedTask; }); await next(); }); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.UseStaticFiles(); app.MapAdminEndpoints(settings); app.MapGet("/", () => Results.Redirect("/admin")); app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) => { // Query token allowed so the status page can be checked in a browser. if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true)) { return Results.Unauthorized(); } return Results.Json(new { connected = hub.HasClient, pendingRequests = hub.PendingRequestCount, tunnelPath = serverSettings.TunnelPath, embeddingCache = new { available = embeddingCache.IsAvailable, count = embeddingCache.Count }, management = new { available = managementStore.IsAvailable }, clients = hub.ClientsSnapshot }); }); app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) => { if (!TokenAuthentication.IsClientAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted); return; } if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted); return; } var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault(); if (string.IsNullOrWhiteSpace(clientId)) { clientId = $"anonymous-{Guid.NewGuid():n}"; } using var socket = await context.WebSockets.AcceptWebSocketAsync(); await hub.AcceptAsync(clientId, socket, context.RequestAborted); }); app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync); app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync) .WithOrder(1000); var elmahService = app.Services.GetRequiredService(); try { app.Run(); } catch (Exception exception) { elmahService.Log(new Error(exception)); throw; }