diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 91bc7f20..3b55ae49 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -56,6 +56,7 @@ using SecRandom.Services.Music; using SecRandom.Services.Settings; using SecRandom.Services.Security; +using SecRandom.Services.SecAgent; using SecRandom.Services.Telemetry; using SecRandom.Services.Verification; using SecRandom.Services.Voice; @@ -774,6 +775,9 @@ private void BuildHost(IPlatformServiceRoot platform) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration. + services.AddHostedService(); + services.AddHostedService(); services.AddSingleton(serviceProvider => new MusicLibraryService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>(), diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs new file mode 100644 index 00000000..3863c987 --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SecRandom.Core.Abstraction.Services; +using SecRandom.Core.Enums; +using SecRandom.Core.Enums.Configs; +using SecRandom.Core.Models.Draw; +using SecRandom.Core.Services.Config; +using SecRandom.Core.Services.Draw; +using SecRandom.Services.Draw; +using SecRandom.Services.Linkage; +using SecRandom.Services.Notification; +using SecRandom.Services.Security; +using SecRandom.Shared.Extensions; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.Services.SecAgent; + +/// +/// Loopback-only REST endpoint for the local SecAgent connector. +/// SecRandom intentionally exposes ordinary HTTP/JSON here; tool discovery and hidden-tool +/// behavior belong to the SecAgent plugin. +/// +public sealed class SecAgentHttpHostedService( + ILogger logger, + IProfileService profileService, + MainConfigHandler configHandler, + IDrawTemporaryRecordService temporaryRecordService, + DrawEngine drawEngine, + LinkageDrawCoordinator linkageDrawCoordinator, + NotificationService notificationService) : BackgroundService +{ + private const string Prefix = "http://127.0.0.1:3910/api/secagent/v1/"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpListener _listener = new(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _listener.Prefixes.Add(Prefix); + try + { + _listener.Start(); + logger.LogInformation("SecAgent loopback REST endpoint started at {Prefix}.", Prefix[..^1]); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start SecAgent loopback REST endpoint at {Prefix}.", Prefix); + return; + } + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false); + _ = Task.Run(() => HandleAsync(context, stoppingToken), CancellationToken.None); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (HttpListenerException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + _listener.Stop(); + _listener.Close(); + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + if (_listener.IsListening) + _listener.Stop(); + return base.StopAsync(cancellationToken); + } + + private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; + var method = context.Request.HttpMethod.ToUpperInvariant(); + JsonNode result; + + if (method == "GET" && path == "/api/secagent/v1/students") + result = ListStudents(); + else if (method == "POST" && path == "/api/secagent/v1/students") + result = UpsertStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "DELETE" && path == "/api/secagent/v1/students") + result = RemoveStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "POST" && path == "/api/secagent/v1/draw/students") + result = await DrawStudentsAsync(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + result = new JsonObject { ["error"] = "Endpoint not found." }; + } + + await WriteJsonAsync(context.Response, result, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.BadRequest, ex.Message).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.Conflict, ex.Message).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "SecAgent REST request failed."); + await WriteErrorAsync(context.Response, HttpStatusCode.InternalServerError, "SecRandom request failed.").ConfigureAwait(false); + } + finally + { + context.Response.Close(); + } + } + + private JsonObject ListStudents() + { + var list = profileService.CurrentStudentList; + return new JsonObject + { + ["profile"] = list?.Name ?? string.Empty, + ["students"] = new JsonArray((list?.Students ?? []).Select(ToJson).ToArray()) + }; + } + + private JsonObject UpsertStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var student = recordId is not null ? list.Students.FirstOrDefault(item => item.RecordId == recordId) : null; + student ??= !string.IsNullOrWhiteSpace(id) ? list.Students.FirstOrDefault(item => item.Id == id) : null; + if (student is null) + { + student = new Student { RecordId = recordId ?? Guid.NewGuid() }; + list.Students.Add(student); + } + + student.Id = id; + student.Name = StringArgument(arguments, "name"); + student.Group = StringArgument(arguments, "group"); + student.Gender = StringArgument(arguments, "gender"); + student.Tags = StringArgument(arguments, "tags"); + student.Exists = arguments["exists"]?.GetValue() ?? true; + if (!student.IsCandidate) + throw new ArgumentException("Student requires a nonblank id or name."); + profileService.SaveProfile(); + return new JsonObject { ["student"] = ToJson(student), ["profile"] = list.Name }; + } + + private JsonObject RemoveStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var name = StringArgument(arguments, "name"); + var matches = list.Students.Where(item => + (recordId is not null && item.RecordId == recordId) + || (!string.IsNullOrWhiteSpace(id) && item.Id == id) + || (!string.IsNullOrWhiteSpace(name) && item.Name == name)).ToList(); + if (matches.Count != 1) + throw new InvalidOperationException(matches.Count == 0 ? "Student was not found." : "Student selector matched more than one student."); + list.Students.Remove(matches[0]); + profileService.SaveProfile(); + return new JsonObject { ["removed"] = ToJson(matches[0]), ["profile"] = list.Name }; + } + + private async Task DrawStudentsAsync(JsonObject arguments, CancellationToken cancellationToken) + { + var mode = StringArgument(arguments, "mode"); + if (mode is not ("flash" or "result_only")) + throw new ArgumentException("mode must be flash or result_only."); + + var requestedCount = Math.Clamp(arguments["count"]?.GetValue() ?? 1, 1, 100); + if (mode == "flash") requestedCount = 1; + var includeTags = StringArray(arguments, "include_tags"); + var excludeTags = StringArray(arguments, "exclude_tags"); + var includeIds = StringArray(arguments, "include_ids"); + var includeNames = StringArray(arguments, "include_names"); + var listName = profileService.CurrentStudentList?.Name ?? string.Empty; + var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, string.Empty, string.Empty); + + var result = await InvokeAuthorizedAsync(SecurityOperation.QuickDrawStart, () => + { + var draw = drawEngine.DrawStudent(requestedCount, student => Matches(student, includeTags, excludeTags, includeIds, includeNames) + && !HasReachedTemporaryLimit(student, temporaryCounts), DrawSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName()); + if (!draw.IsSuccess || draw.Result.Count == 0) + return Task.FromResult(draw); + + profileService.RecordStudentHistory(draw.Result, DateTime.Now, requestedCount, + drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType, + courseName: linkageDrawCoordinator.GetCourseName()); + temporaryRecordService.RecordStudents(listName, string.Empty, string.Empty, draw.Result); + if (mode == "flash") + notificationService.QueueStudents(NotificationSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName(), draw.Result); + return Task.FromResult(draw); + }, cancellationToken).ConfigureAwait(false); + + return new JsonObject + { + ["mode"] = mode, + ["count"] = result.Result.Count, + ["status"] = result.Status.ToString(), + ["profile"] = listName, + ["students"] = new JsonArray(result.Result.Select(ToJson).ToArray()) + }; + } + + private async Task> InvokeAuthorizedAsync(SecurityOperation operation, Func>> action, CancellationToken cancellationToken) + { + DrawResult? result = null; + var authorized = await linkageDrawCoordinator.AuthorizeAsync(operation, + async () => result = await action().ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + return authorized && result is not null ? result : new DrawResult { Status = DrawStatus.Failure }; + } + + private bool HasReachedTemporaryLimit(Student student, IReadOnlyDictionary temporaryCounts) + { + var settings = configHandler.Data.QuickDrawSettings; + var threshold = settings.DrawMode switch + { + DrawMode.Repeat => 0, + DrawMode.NoRepeat => 1, + DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat), + _ => 1 + }; + return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold; + } + + private static bool Matches(Student student, IReadOnlyCollection includeTags, IReadOnlyCollection excludeTags, + IReadOnlyCollection includeIds, IReadOnlyCollection includeNames) + { + var tags = student.Tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return includeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && excludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && (includeIds.Count == 0 || includeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase)) + && (includeNames.Count == 0 || includeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase)); + } + + private static JsonObject ToJson(Student student) => new() + { + ["record_id"] = ProfileRecordIdentity.EnsureRecordId(student), + ["id"] = student.Id, + ["name"] = student.Name, + ["group"] = student.Group, + ["gender"] = student.Gender, + ["tags"] = student.Tags, + ["exists"] = student.Exists + }; + + private static async Task ReadBodyAsync(HttpListenerRequest request, CancellationToken cancellationToken) + { + var body = await JsonNode.ParseAsync(request.InputStream, cancellationToken: cancellationToken).ConfigureAwait(false) as JsonObject; + return body ?? throw new ArgumentException("Request body must be a JSON object."); + } + + private static string StringArgument(JsonObject arguments, string name) => arguments[name]?.GetValue()?.Trim() ?? string.Empty; + private static Guid? ParseGuid(string? value) => Guid.TryParse(value, out var result) ? result : null; + + private static IReadOnlyList StringArray(JsonObject arguments, string name) + => arguments[name] is JsonArray array + ? array.Select(item => item?.GetValue()?.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray() + : []; + + private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode value, CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(value.ToJsonString(JsonOptions)); + response.ContentType = "application/json"; + response.ContentEncoding = Encoding.UTF8; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + private static Task WriteErrorAsync(HttpListenerResponse response, HttpStatusCode status, string message) + { + response.StatusCode = (int)status; + return WriteJsonAsync(response, new JsonObject { ["error"] = message }, CancellationToken.None); + } +} diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs new file mode 100644 index 00000000..daa45dcc --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SecRandom.Services.SecAgent; + +/// +/// Quietly asks a running local SecAgent to install the SecRandom connector. +/// This is deliberately best-effort: SecRandom remains fully usable without SecAgent. +/// +public sealed class SecAgentPluginBootstrapHostedService( + IHttpClientFactory httpClientFactory, + ILogger logger) : BackgroundService +{ + private const string ConnectorPluginId = "secrandom"; + private const string ConnectorPluginVersion = "0.1.1"; + private static readonly Uri BaseUri = new("http://127.0.0.1:42189/"); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Give the desktop host time to finish its own startup, and never hold up the UI. + try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EnsurePluginAsync(stoppingToken).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (HttpRequestException) + { + // SecAgent is optional and may simply not be installed/running. + } + catch (Exception ex) + { + logger.LogDebug(ex, "SecAgent connector bootstrap was skipped."); + return; + } + + try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + } + + private async Task EnsurePluginAsync(CancellationToken cancellationToken) + { + using var client = httpClientFactory.CreateClient(); + client.BaseAddress = BaseUri; + client.Timeout = TimeSpan.FromSeconds(2); + + using var health = await client.GetAsync("health", cancellationToken).ConfigureAwait(false); + if (!health.IsSuccessStatusCode) return; + + var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false); + var current = installed?.Plugins?.FirstOrDefault(plugin => + string.Equals(plugin.Id, ConnectorPluginId, StringComparison.OrdinalIgnoreCase)); + if (current is not null && !IsOlderVersion(current.Version, ConnectorPluginVersion)) + return; + + var request = current is null + ? new { pluginId = ConnectorPluginId, version = (string?)null } + : new { pluginId = ConnectorPluginId, version = (string?)ConnectorPluginVersion }; + using var response = await client.PostAsJsonAsync("plugins/install", request, JsonOptions, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + logger.LogInformation("Requested local SecAgent to install/update the SecRandom connector plugin to {Version}.", ConnectorPluginVersion); + else + logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode); + } + + private sealed class PluginListResponse + { + public List? Plugins { get; init; } + } + + private sealed class PluginInfo + { + public string? Id { get; init; } + public string? Version { get; init; } + } + + private static bool IsOlderVersion(string? current, string desired) + { + if (Version.TryParse(current, out var currentVersion) && Version.TryParse(desired, out var desiredVersion)) + return currentVersion < desiredVersion; + + return !string.Equals(current, desired, StringComparison.OrdinalIgnoreCase); + } +}