198 lines
14 KiB
C#
198 lines
14 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using WxAgent.Core;
|
|
|
|
namespace WxAgent.Service;
|
|
|
|
public static class ServiceHost
|
|
{
|
|
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
|
{ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
|
|
public static readonly JsonSerializerOptions ConfigurationJson = new(Json)
|
|
{ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip };
|
|
|
|
public static WebApplication Build(ServiceOptions options, IAgentBackend backend,
|
|
Action<WebApplicationBuilder>? configure = null, string? logPath = null)
|
|
{
|
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
|
{ Args = [], WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot") });
|
|
var runtimeLogPath = logPath ?? Path.Combine(AppContext.BaseDirectory, "wxagent.log");
|
|
var fileLogger = new FileLoggerProvider(runtimeLogPath);
|
|
builder.Logging.ClearProviders();
|
|
builder.Logging.AddProvider(fileLogger);
|
|
try
|
|
{
|
|
options.Validate();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
RuntimeLog.Append(runtimeLogPath, LogLevel.Error, "WxAgent.Service", "Service configuration validation failed.", exception);
|
|
fileLogger.Dispose();
|
|
throw;
|
|
}
|
|
RuntimeLog.Append(runtimeLogPath, LogLevel.Information, "WxAgent.Service", $"Service configured for {options.ListenUrl}.");
|
|
builder.WebHost.UseUrls(options.ListenUrl);
|
|
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024);
|
|
// Keep operational logs beside the executable; credentials, headers and request bodies are not logged.
|
|
builder.Logging.SetMinimumLevel(LogLevel.Information);
|
|
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.None);
|
|
builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.None);
|
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
|
|
builder.Services.AddSingleton(options);
|
|
builder.Services.AddSingleton(backend);
|
|
builder.Services.AddSingleton<ServiceSecurity>();
|
|
builder.Services.AddSingleton<OperationStore>();
|
|
builder.Services.AddSingleton<EventHub>();
|
|
builder.Services.AddSingleton<ArtifactStore>();
|
|
builder.Services.AddSingleton<AccountBindingStore>();
|
|
builder.Services.AddSingleton<OperationQueue>();
|
|
builder.Services.AddHostedService(p => p.GetRequiredService<OperationQueue>());
|
|
if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
|
builder.Services.AddSingleton(new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json")));
|
|
builder.Services.AddHostedService<EventPump>();
|
|
if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
|
builder.Services.AddHostedService<RemoteAgentHostedService>();
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<AgentService>();
|
|
builder.Services.AddMcpServer().WithHttpTransport(o => o.Stateless = true).WithTools<AgentTools>();
|
|
configure?.Invoke(builder);
|
|
builder.Services.AddRouting();
|
|
var app = builder.Build();
|
|
app.Use(async (context, next) =>
|
|
{
|
|
var correlationId = Guid.NewGuid().ToString("N");
|
|
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("WxAgent.Http");
|
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
context.Response.Headers["Referrer-Policy"] = "no-referrer";
|
|
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
|
try
|
|
{
|
|
var security = context.RequestServices.GetRequiredService<ServiceSecurity>();
|
|
security.ValidateSource(context);
|
|
// There are no tokens in query strings, including MCP initialization URLs.
|
|
if (context.Request.Query.Keys.Any(k => k.Contains("token", StringComparison.OrdinalIgnoreCase)))
|
|
throw new ServiceException("InvalidRequest", 400, "Credentials must not be supplied in URLs.");
|
|
var path = context.Request.Path.Value ?? "/";
|
|
var isStatic = !path.StartsWith("/api/", StringComparison.Ordinal) && path != "/mcp";
|
|
if (!(path == "/api/v1/login" && HttpMethods.IsPost(context.Request.Method)) && !isStatic)
|
|
context.Items[typeof(ServiceIdentity)] = security.AuthenticateRequest(context);
|
|
await next(context);
|
|
}
|
|
catch (Exception e) when (!context.Response.HasStarted)
|
|
{
|
|
var (code, status, message) = e switch
|
|
{
|
|
ServiceException se => (se.Code, se.StatusCode, se.Message),
|
|
BadHttpRequestException or JsonException => ("InvalidRequest", 400, "Invalid request."),
|
|
OperationCanceledException => ("Cancelled", 408, "Request cancelled or timed out."),
|
|
_ => ("InternalError", 500, "Request failed; use the correlation ID for diagnosis.")
|
|
};
|
|
logger.LogError(e, "HTTP {Method} {Path} failed with {StatusCode} {ErrorCode}; correlationId={CorrelationId}",
|
|
context.Request.Method, context.Request.Path, status, code, correlationId);
|
|
context.Response.StatusCode = status;
|
|
await context.Response.WriteAsJsonAsync(new { correlationId, error = new { code, message, stage = "request", retry = false } });
|
|
}
|
|
finally
|
|
{
|
|
logger.LogInformation("HTTP {Method} {Path} -> {StatusCode}; correlationId={CorrelationId}; elapsedMs={ElapsedMs}",
|
|
context.Request.Method, context.Request.Path, context.Response.StatusCode, correlationId, stopwatch.ElapsedMilliseconds);
|
|
}
|
|
});
|
|
app.UseDefaultFiles();
|
|
app.UseStaticFiles();
|
|
app.MapPost("/api/v1/login", (HttpContext context, LoginRequest request, ServiceSecurity security) => security.Login(context, request.Token ?? ""));
|
|
app.MapPost("/api/v1/logout", (HttpContext context, ServiceSecurity security) => { security.Logout(context); return Results.NoContent(); });
|
|
app.MapGet("/api/v1/status", (AgentService service, CancellationToken ct) => service.StatusAsync(ct));
|
|
app.MapGet("/api/v1/diagnostics", (AgentService service, CancellationToken ct) => service.DiagnoseAsync(ct));
|
|
app.MapGet("/api/v1/capabilities", (AgentService service) => service.Capabilities());
|
|
app.MapGet("/api/v1/accounts", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.AccountsAsync(limit ?? 50, offset ?? 0, ct));
|
|
app.MapGet("/api/v1/accounts/bindings", (AgentService service) => service.Bindings());
|
|
app.MapPost("/api/v1/accounts/bind", (BindAccountRequest request, AgentService service, CancellationToken ct) => service.BindAccountAsync(request.AccountId, request.TargetId, ct));
|
|
app.MapPost("/api/v1/accounts/unbind", async (UnbindAccountRequest request, AgentService service, CancellationToken ct) => { await service.UnbindAccountAsync(request.AccountId, ct); return Results.NoContent(); });
|
|
app.MapGet("/api/v1/ui-targets", (AgentService service, CancellationToken ct) => service.UiTargetsAsync(ct));
|
|
app.MapGet("/api/v1/sessions", (string? accountId, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(accountId, limit ?? 50, offset ?? 0, ct));
|
|
app.MapGet("/api/v1/sessions/search", (string? accountId, string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(accountId, query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct));
|
|
app.MapGet("/api/v1/sessions/current", (string? accountId, AgentService service, CancellationToken ct) => service.CurrentSessionAsync(accountId, ct));
|
|
app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.AccountId, request.Direction, request.Pages, ct));
|
|
app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AccountId, request.AutomationId, ct));
|
|
app.MapGet("/api/v1/messages", (string? accountId, string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(accountId, session, limit ?? 50, offset ?? 0, includeContent ?? false, ct));
|
|
app.MapGet("/api/v1/contacts", (string? accountId, string? contains, bool? groupsOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.ContactsAsync(accountId, contains, groupsOnly, limit ?? 50, offset ?? 0, ct));
|
|
app.MapGet("/api/v1/groups/{accountId}/{group}/members", (string accountId, string group, int? limit, int? offset, AgentService service, CancellationToken ct) => service.GroupMembersAsync(accountId, group, limit ?? 50, offset ?? 0, ct));
|
|
app.MapGet("/api/v1/db/messages", (string accountId, string chatId, int? limit, int? offset, long? localId, AgentService service, CancellationToken ct) => service.DatabaseMessagesAsync(accountId, chatId, limit ?? 50, offset ?? 0, localId, ct));
|
|
app.MapGet("/api/v1/db/merged", (string accountId, string chatId, long localId, AgentService service, CancellationToken ct) => service.DatabaseMergedAsync(accountId, chatId, localId, ct));
|
|
app.MapGet("/api/v1/events", StreamEvents);
|
|
app.MapPost("/api/v1/files", async (HttpContext context, AgentService service, CancellationToken ct) =>
|
|
{
|
|
if (!context.Request.HasFormContentType) throw new ServiceException("InvalidRequest", 400, "multipart/form-data is required.");
|
|
var form = await context.Request.ReadFormAsync(ct);
|
|
if (form.Files.Count != 1) throw new ServiceException("InvalidRequest", 400, "Exactly one file is required.");
|
|
return Results.Ok(await service.UploadAsync(form.Files[0], ct));
|
|
});
|
|
app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream"));
|
|
app.MapPost("/api/v1/operations", async (OperationSubmitRequest request, AgentService service, CancellationToken ct) => await service.SubmitOperationAsync(request, ct));
|
|
app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0));
|
|
app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id));
|
|
app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id));
|
|
app.MapMcp("/mcp");
|
|
return app;
|
|
}
|
|
|
|
private static async Task StreamEvents(HttpContext context, EventHub hub, ServiceSecurity security, AgentService service)
|
|
{
|
|
var identity = context.Items[typeof(ServiceIdentity)] as ServiceIdentity
|
|
?? throw new ServiceException("Unauthorized", 401, "Authentication required.");
|
|
service.RequireEvents();
|
|
var after = context.Request.Headers["Last-Event-ID"].ToString();
|
|
var (id, replay, gap) = hub.Subscribe(identity.PrincipalId, string.IsNullOrWhiteSpace(after) ? null : after);
|
|
context.Response.ContentType = "text/event-stream";
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
await context.Response.StartAsync(context.RequestAborted);
|
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
|
if (!hub.TryGet(id, identity.PrincipalId, out var subscription))
|
|
throw new ServiceException("SubscriptionUnavailable", 503, "Could not create event subscription.");
|
|
try
|
|
{
|
|
if (gap) await WriteEvent(context, "gap", new { resyncRequired = true });
|
|
foreach (var item in replay) await WriteEvent(context, "message", item);
|
|
if (replay.Count != 0) await context.Response.Body.FlushAsync(context.RequestAborted);
|
|
while (!context.RequestAborted.IsCancellationRequested)
|
|
{
|
|
using var wake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
|
|
wake.CancelAfter(TimeSpan.FromSeconds(15));
|
|
bool available;
|
|
try { available = await subscription.Channel.Reader.WaitToReadAsync(wake.Token); }
|
|
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
|
|
{ service.RequireEvents(); continue; }
|
|
if (!available) break;
|
|
security.RequireCurrent(identity, "read");
|
|
while (subscription.Channel.Reader.TryRead(out var item))
|
|
await WriteEvent(context, item.Kind == "gap" ? "gap" : "message", item);
|
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { }
|
|
catch (ServiceException) when (context.Response.HasStarted) { }
|
|
finally { hub.Remove(id); }
|
|
}
|
|
|
|
private static async Task WriteEvent(HttpContext context, string name, object value)
|
|
{
|
|
var id = value is AgentEvent e ? e.EventId : "control";
|
|
await context.Response.WriteAsync($"id: {id}\nevent: {name}\ndata: {JsonSerializer.Serialize(value, Json)}\n\n", context.RequestAborted);
|
|
}
|
|
|
|
public sealed record LoginRequest(string Token);
|
|
public sealed record ScrollRequest(string? AccountId, string Direction, int Pages = 1);
|
|
public sealed record OpenSessionRequest(string? AccountId, string AutomationId);
|
|
public sealed record BindAccountRequest(string AccountId, string TargetId);
|
|
public sealed record UnbindAccountRequest(string AccountId);
|
|
}
|