373 lines
16 KiB
C#
373 lines
16 KiB
C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Http.Resilience;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using ModelContextProtocol.AspNetCore;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Integrations;
|
|
using Nexus.Api.RateLimiting;
|
|
using Nexus.Api.Repositories;
|
|
using Nexus.Api.Routing;
|
|
using Nexus.Api.Services;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.RateLimiting;
|
|
|
|
namespace Nexus.Api.Extensions;
|
|
|
|
/// <summary>
|
|
/// Extension methods for registering Nexus application services in the DI container.
|
|
/// </summary>
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Configures JWT authentication, authorization, and antiforgery.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
var jwtKey = configuration["Jwt:Key"];
|
|
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
|
|
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
|
|
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
|
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
|
|
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.MapInboundClaims = false;
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = jwtIssuer,
|
|
ValidAudience = jwtAudience,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
|
NameClaimType = JwtRegisteredClaimNames.Sub,
|
|
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
|
ClockSkew = TimeSpan.FromSeconds(30)
|
|
};
|
|
});
|
|
|
|
services.AddAuthorization(options =>
|
|
{
|
|
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
});
|
|
services.AddAntiforgery(options =>
|
|
{
|
|
options.HeaderName = "X-CSRF-TOKEN";
|
|
options.Cookie.Name = "nexus-csrf";
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.Cookie.HttpOnly = false;
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures rate limiting policies (auth and agents).
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
|
|
{
|
|
services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
|
|
options.OnRejected = async (context, ct) =>
|
|
{
|
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
|
context.HttpContext.Response.Headers.ContentType = "application/problem+json";
|
|
|
|
var retryAfterSeconds = 60;
|
|
|
|
// Try to read retry-after info from the metadata
|
|
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
|
{
|
|
retryAfterSeconds = (int)retryAfter.TotalSeconds;
|
|
}
|
|
|
|
// Set standard headers
|
|
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
|
|
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
|
|
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
|
|
|
var body = new ProblemDetails
|
|
{
|
|
Type = "https://httpstatuses.com/429",
|
|
Title = "Rate limit exceeded",
|
|
Status = StatusCodes.Status429TooManyRequests,
|
|
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
|
|
};
|
|
body.Extensions["code"] = "rate_limit_exceeded";
|
|
body.Extensions["remaining"] = 0;
|
|
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
|
body.Extensions["traceId"] =
|
|
System.Diagnostics.Activity.Current?.Id
|
|
?? context.HttpContext.TraceIdentifier;
|
|
|
|
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
|
|
};
|
|
|
|
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 5,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
}));
|
|
|
|
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 30,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
}));
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures forwarded headers for reverse proxy scenarios.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusForwardedHeaders(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
var forwardLimit = configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1;
|
|
if (forwardLimit is < 1 or > 5)
|
|
throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be between 1 and 5.");
|
|
options.ForwardLimit = forwardLimit;
|
|
|
|
foreach (var configuredProxy in configuration
|
|
.GetSection("ForwardedHeaders:KnownProxies")
|
|
.Get<string[]>() ?? [])
|
|
{
|
|
if (string.IsNullOrWhiteSpace(configuredProxy))
|
|
continue;
|
|
|
|
if (!IPAddress.TryParse(configuredProxy, out var proxy))
|
|
throw new InvalidOperationException(
|
|
$"ForwardedHeaders:KnownProxies contains invalid IP address '{configuredProxy}'.");
|
|
|
|
options.KnownProxies.Add(proxy);
|
|
}
|
|
|
|
foreach (var configuredNetwork in configuration
|
|
.GetSection("ForwardedHeaders:KnownNetworks")
|
|
.Get<string[]>() ?? [])
|
|
{
|
|
if (string.IsNullOrWhiteSpace(configuredNetwork))
|
|
continue;
|
|
|
|
if (!System.Net.IPNetwork.TryParse(configuredNetwork, out var network))
|
|
throw new InvalidOperationException(
|
|
$"ForwardedHeaders:KnownNetworks contains invalid CIDR '{configuredNetwork}'.");
|
|
|
|
options.KnownIPNetworks.Add(network);
|
|
}
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures Swagger and JSON serialization options.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
|
|
{
|
|
services.AddEndpointsApiExplorer();
|
|
services.AddSwaggerGen();
|
|
services.ConfigureHttpJsonOptions(options =>
|
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the Entity Framework Core DbContext with Npgsql.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
services.AddDbContext<NexusDbContext>(options =>
|
|
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers typed and named HTTP clients for OpenClaw integration.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
var runtimeReadClient = services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
|
{
|
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
|
});
|
|
AddOpenClawReadResilience(runtimeReadClient);
|
|
|
|
var gatewayReadClient = services.AddHttpClient("gateway", client =>
|
|
{
|
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
|
});
|
|
AddOpenClawReadResilience(gatewayReadClient);
|
|
|
|
var historyReadClient = services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
|
{
|
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789");
|
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
|
});
|
|
AddOpenClawReadResilience(historyReadClient);
|
|
|
|
return services;
|
|
}
|
|
|
|
private static void AddOpenClawReadResilience(IHttpClientBuilder client)
|
|
{
|
|
client.AddStandardResilienceHandler(options =>
|
|
{
|
|
options.RateLimiter.DefaultRateLimiterOptions.PermitLimit = 4;
|
|
options.RateLimiter.DefaultRateLimiterOptions.QueueLimit = 0;
|
|
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
|
|
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30);
|
|
options.Retry.MaxRetryAttempts = 2;
|
|
options.Retry.Delay = TimeSpan.FromMilliseconds(250);
|
|
options.Retry.UseJitter = true;
|
|
options.Retry.DisableForUnsafeHttpMethods();
|
|
options.CircuitBreaker.FailureRatio = 0.5;
|
|
options.CircuitBreaker.MinimumThroughput = 4;
|
|
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
|
|
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers application domain services (transient, scoped, singleton).
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusApplicationServices(
|
|
this IServiceCollection services,
|
|
bool includeHostedServices = true)
|
|
{
|
|
services.AddMcpServer()
|
|
.WithHttpTransport(options => options.Stateless = true)
|
|
.WithTools<NexusMcpTools>();
|
|
|
|
services.AddOptions<StaleTaskRecoveryOptions>()
|
|
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
|
services.AddOptions<AgentProvisioningOptions>()
|
|
.BindConfiguration(AgentProvisioningOptions.SectionName);
|
|
services.AddHttpContextAccessor();
|
|
services.AddSingleton<LoginAttemptTracker>();
|
|
services.AddTransient<ModelRoutingService>();
|
|
services.AddScoped<IAuthService, AuthService>();
|
|
services.AddScoped<IAgentService, AgentService>();
|
|
services.AddScoped<IDashboardService, DashboardService>();
|
|
services.AddScoped<IProjectService, ProjectService>();
|
|
services.AddScoped<ITaskService, TaskService>();
|
|
services.AddScoped<IOperationsService, OperationsService>();
|
|
services.AddScoped<ITeamService, TeamService>();
|
|
services.AddScoped<IMemoryService, MemoryService>();
|
|
services.AddScoped<IIncidentService, IncidentService>();
|
|
services.AddScoped<IDocService, DocService>();
|
|
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
|
services.AddSingleton<DomainEventStreamService>();
|
|
services.AddSingleton<IDomainEventStreamService>(serviceProvider =>
|
|
serviceProvider.GetRequiredService<DomainEventStreamService>());
|
|
services.AddScoped<INotificationService, NotificationService>();
|
|
services.AddScoped<ICalendarService, CalendarService>();
|
|
services.AddScoped<IOpenClawControlService, OpenClawControlService>();
|
|
services.AddScoped<IOpenClawAgentConfigurationService, OpenClawAgentConfigurationService>();
|
|
services.AddScoped<IOpenClawSetupService, OpenClawSetupService>();
|
|
services.AddSingleton<IOpenClawWizardService, OpenClawWizardService>();
|
|
services.AddSingleton<IOpenClawManagementState, OpenClawManagementState>();
|
|
services.AddSingleton<IOpenClawWriteGate, OpenClawWriteGate>();
|
|
services.AddSingleton<IOpenClawEventProjectionService, OpenClawEventProjectionService>();
|
|
services.AddSingleton<IOpenClawRunGateway, OpenClawRunGateway>();
|
|
services.AddScoped<IOpenClawRunService, OpenClawRunService>();
|
|
services.AddScoped<IOpenClawChatService, OpenClawChatService>();
|
|
services.AddScoped<IAgentProposalService, AgentProposalService>();
|
|
services.AddSingleton<AgentProvisioningSignal>();
|
|
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
|
|
|
|
// ── Gateway WebSocket Connector ──
|
|
services.AddOptions<GatewayConnectorOptions>()
|
|
.BindConfiguration(GatewayConnectorOptions.SectionName);
|
|
services.AddSingleton<IOpenClawDeviceIdentityStore, OpenClawDeviceIdentityStore>();
|
|
services.AddSingleton<
|
|
IOpenClawOperationAuditStore,
|
|
PostgresOpenClawOperationAuditStore>();
|
|
services.AddSingleton<IGatewayConnector, GatewayConnector>();
|
|
|
|
if (includeHostedServices)
|
|
{
|
|
services.AddHostedService<OpenClawManagementStateInitializer>();
|
|
services.AddHostedService(serviceProvider =>
|
|
serviceProvider.GetRequiredService<DomainEventStreamService>());
|
|
services.AddHostedService<AgentProvisioningWorker>();
|
|
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
|
|
services.AddHostedService(serviceProvider =>
|
|
(GatewayConnector)serviceProvider.GetRequiredService<IGatewayConnector>());
|
|
services.AddHostedService<OpenClawEventSubscriptionCoordinator>();
|
|
services.AddHostedService<OpenClawRunEventReconciler>();
|
|
}
|
|
|
|
// ── Backend Bridge (Agent-Command-Service) ──
|
|
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers data repositories.
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
|
|
{
|
|
services.AddScoped<IUserRepository, UserRepository>();
|
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
|
services.AddScoped<ITaskRepository, TaskRepository>();
|
|
services.AddScoped<IActivityRepository, ActivityRepository>();
|
|
services.AddScoped<IOpenClawRunRepository, OpenClawRunRepository>();
|
|
services.AddScoped<IOpenClawConnectionProfileRepository, OpenClawConnectionProfileRepository>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures health checks (PostgreSQL connectivity and runtime status).
|
|
/// </summary>
|
|
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
|
{
|
|
services.AddHealthChecks()
|
|
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
|
|
|
return services;
|
|
}
|
|
}
|