From aa8da317cfac47524a67d093339cc25b386580b5 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 17 Aug 2026 23:55:29 +0700 Subject: [PATCH 1/3] feat(control): add operator endpoint for node enrollment code --- .../ControlOptions.cs | 16 + .../ControlStore.cs | 26 ++ .../NodeEnrollmentService.cs | 223 ++++++++++ src/ServerMonitorManager.Control/Program.cs | 53 +++ .../NodeEnrollmentModels.cs | 7 + .../SmmJsonContext.cs | 1 + .../NodeEnrollmentCodeTests.cs | 400 ++++++++++++++++++ 7 files changed, 726 insertions(+) create mode 100644 src/ServerMonitorManager.Control/NodeEnrollmentService.cs create mode 100644 src/ServerMonitorManager.Core/NodeEnrollmentModels.cs create mode 100644 tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs diff --git a/src/ServerMonitorManager.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs index d1dfcc1..fbe6346 100644 --- a/src/ServerMonitorManager.Control/ControlOptions.cs +++ b/src/ServerMonitorManager.Control/ControlOptions.cs @@ -46,4 +46,20 @@ public sealed class ControlOptions public string HubHelperPath { get; set; } = "/usr/local/libexec/ochenstarik-smm-policy-apply"; public string PrivilegeEscalationPath { get; set; } = "/usr/bin/sudo"; + + public string? PublicUrl { get; set; } + + public string PublicUrlPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/control-public-url"; + + public string MeshEnvironmentPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/mesh.env"; + + public string MeshNodesPath { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/mesh/nodes.tsv"; + + public string HubPublicKeyPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/wireguard/hub.pub"; + + public string? HubEndpoint { get; set; } + + public string? HubPublicKey { get; set; } + + public string MeshNetwork { get; set; } = "10.77.0.0/24"; } diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs index 0dc346b..1fe996a 100644 --- a/src/ServerMonitorManager.Control/ControlStore.cs +++ b/src/ServerMonitorManager.Control/ControlStore.cs @@ -444,6 +444,32 @@ public sealed partial class ControlStore(IOptions options) return token; } + public async Task RecordEnrollmentCodeIssuedAsync( + string nodeId, + string actor, + string nodeAddress, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + "agent.enrollment_code.issued", + nodeId, + JsonSerializer.Serialize(new + { + node_id = nodeId, + actor, + node_address = nodeAddress, + expires_at = expiresAt + }), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + public async Task CreateDeviceEnrollmentTokenAsync( string deviceId, TimeSpan lifetime, diff --git a/src/ServerMonitorManager.Control/NodeEnrollmentService.cs b/src/ServerMonitorManager.Control/NodeEnrollmentService.cs new file mode 100644 index 0000000..b96c2f3 --- /dev/null +++ b/src/ServerMonitorManager.Control/NodeEnrollmentService.cs @@ -0,0 +1,223 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using ServerMonitorManager.Core; + +namespace ServerMonitorManager.Control; + +public sealed class NodeEnrollmentService +{ + private readonly IOptions _options; + private readonly ControlStore _store; + private readonly CertificateAuthority _authority; + private readonly ControlEventBroker _broker; + private readonly SemaphoreSlim _meshLock = new(1, 1); + + public NodeEnrollmentService( + IOptions options, + ControlStore store, + CertificateAuthority authority, + ControlEventBroker broker) + { + _options = options; + _store = store; + _authority = authority; + _broker = broker; + } + + public async Task CreateEnrollmentCodeAsync( + string nodeId, + string actor, + CancellationToken cancellationToken = default) + { + if (!NodeIdValidator.IsValid(nodeId)) + { + throw new ArgumentException("Node id must contain 1-63 lowercase letters, digits, or hyphens.", nameof(nodeId)); + } + + var controlUrl = ResolveControlPublicUrl(); + var (caPem, caFingerprint) = ResolveCertificateAuthorityInfo(); + var (hubEndpoint, hubPublicKey, meshNetwork) = ResolveMeshConfiguration(); + + string nodeAddress; + await _meshLock.WaitAsync(cancellationToken); + try + { + nodeAddress = await ReserveNodeAddressAsync(nodeId, cancellationToken); + } + finally + { + _meshLock.Release(); + } + + var lifetime = TimeSpan.FromMinutes(10); + var token = await _store.CreateEnrollmentTokenAsync(nodeId, lifetime, cancellationToken); + var expiresAt = DateTimeOffset.UtcNow.Add(lifetime); + + await _store.RecordEnrollmentCodeIssuedAsync(nodeId, actor, nodeAddress, expiresAt, cancellationToken); + _broker.Publish( + "agent.enrollment_code.issued", + nodeId, + JsonSerializer.Serialize(new + { + node_id = nodeId, + actor, + node_address = nodeAddress, + expires_at = expiresAt + })); + + var code = string.Join(".", + "SMMNODE2", + Base64UrlEncode(controlUrl), + Base64UrlEncode(caPem), + Base64UrlEncode(nodeId), + Base64UrlEncode(token), + Base64UrlEncode(hubEndpoint), + Base64UrlEncode(hubPublicKey), + Base64UrlEncode(nodeAddress), + Base64UrlEncode(meshNetwork)); + + return new NodeEnrollmentCodeResponse(nodeId, code, caFingerprint, expiresAt); + } + + private string ResolveControlPublicUrl() + { + var options = _options.Value; + if (!string.IsNullOrWhiteSpace(options.PublicUrl)) + { + return options.PublicUrl.Trim(); + } + + if (File.Exists(options.PublicUrlPath)) + { + var content = File.ReadAllText(options.PublicUrlPath).Trim(); + if (!string.IsNullOrWhiteSpace(content)) + { + return content; + } + } + + throw new InvalidOperationException("Control public URL is missing."); + } + + private (string Pem, string Fingerprint) ResolveCertificateAuthorityInfo() + { + var cert = _authority.PublicCertificate; + var pem = cert.ExportCertificatePem(); + var hash = cert.GetCertHash(HashAlgorithmName.SHA256); + var fingerprint = string.Join(":", hash.Select(b => b.ToString("X2"))); + return (pem, fingerprint); + } + + private (string HubEndpoint, string HubPublicKey, string MeshNetwork) ResolveMeshConfiguration() + { + var options = _options.Value; + var hubEndpoint = options.HubEndpoint; + var hubPublicKey = options.HubPublicKey; + var meshNetwork = options.MeshNetwork ?? "10.77.0.0/24"; + + if (File.Exists(options.MeshEnvironmentPath)) + { + foreach (var line in File.ReadLines(options.MeshEnvironmentPath)) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) + { + continue; + } + + var separatorIndex = trimmed.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + var key = trimmed[..separatorIndex].Trim(); + var value = trimmed[(separatorIndex + 1)..].Trim(); + if (string.Equals(key, "HUB_ENDPOINT", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(hubEndpoint)) + { + hubEndpoint = value; + } + else if (string.Equals(key, "HUB_PUBLIC_KEY", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(hubPublicKey)) + { + hubPublicKey = value; + } + else if (string.Equals(key, "MESH_NETWORK", StringComparison.OrdinalIgnoreCase)) + { + meshNetwork = value; + } + } + } + + if (string.IsNullOrWhiteSpace(hubPublicKey) && File.Exists(options.HubPublicKeyPath)) + { + hubPublicKey = File.ReadAllText(options.HubPublicKeyPath).Trim(); + } + + if (string.IsNullOrWhiteSpace(hubEndpoint) || string.IsNullOrWhiteSpace(hubPublicKey)) + { + throw new InvalidOperationException("Mesh Hub is not initialized."); + } + + return (hubEndpoint, hubPublicKey, meshNetwork); + } + + private async Task ReserveNodeAddressAsync(string nodeId, CancellationToken cancellationToken) + { + var path = _options.Value.MeshNodesPath; + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var lines = File.Exists(path) + ? (await File.ReadAllLinesAsync(path, cancellationToken)).ToList() + : new List(); + + var usedAddresses = new HashSet(StringComparer.Ordinal); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var parts = line.Split('\t'); + if (parts.Length >= 2) + { + var currentId = parts[0].Trim(); + var currentAddr = parts[1].Trim(); + if (string.Equals(currentId, nodeId, StringComparison.Ordinal)) + { + return currentAddr; + } + usedAddresses.Add(currentAddr); + } + } + + for (var host = 2; host <= 254; host++) + { + var candidate = $"10.77.0.{host}"; + if (!usedAddresses.Contains(candidate)) + { + var record = $"{nodeId}\t{candidate}\t-\treserved"; + lines.Add(record); + await File.WriteAllLinesAsync(path, lines, cancellationToken); + return candidate; + } + } + + throw new InvalidOperationException("Mesh address pool is exhausted."); + } + + public static string Base64UrlEncode(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 94b18de..05fc741 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -60,6 +60,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -602,6 +603,58 @@ control.MapPost("/certificates/renew", async ( return Results.Conflict(new ProblemDetails { Title = exception.Message }); } }); +control.MapPost("/agents/{nodeId}/enrollment-code", async ( + string nodeId, + HttpContext context, + NodeEnrollmentService enrollmentService, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(nodeId)) + { + return Results.ValidationProblem(new Dictionary + { + ["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."] + }); + } + + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; + try + { + var response = await enrollmentService.CreateEnrollmentCodeAsync( + nodeId, actor, cancellationToken); + return Results.Ok(response); + } + catch (InvalidOperationException exception) + { + return Results.Conflict(new ProblemDetails { Title = exception.Message }); + } +}).RequireRateLimiting("enrollment"); +control.MapPost("/nodes/{nodeId}/enrollment-code", async ( + string nodeId, + HttpContext context, + NodeEnrollmentService enrollmentService, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(nodeId)) + { + return Results.ValidationProblem(new Dictionary + { + ["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."] + }); + } + + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; + try + { + var response = await enrollmentService.CreateEnrollmentCodeAsync( + nodeId, actor, cancellationToken); + return Results.Ok(response); + } + catch (InvalidOperationException exception) + { + return Results.Conflict(new ProblemDetails { Title = exception.Message }); + } +}).RequireRateLimiting("enrollment"); control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); control.MapGet("/provisioning/catalogs/system-base-install/1", () => diff --git a/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs b/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs new file mode 100644 index 0000000..ab8a344 --- /dev/null +++ b/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs @@ -0,0 +1,7 @@ +namespace ServerMonitorManager.Core; + +public sealed record NodeEnrollmentCodeResponse( + string NodeId, + string Code, + string CaFingerprint, + DateTimeOffset ExpiresAt); diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index ec2a766..d0aeccf 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -57,4 +57,5 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(ProvisioningJob[]))] [JsonSerializable(typeof(ProvisioningEvent))] [JsonSerializable(typeof(ProvisioningEvent[]))] +[JsonSerializable(typeof(NodeEnrollmentCodeResponse))] public sealed partial class SmmJsonContext : JsonSerializerContext; diff --git a/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs b/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs new file mode 100644 index 0000000..da4875b --- /dev/null +++ b/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs @@ -0,0 +1,400 @@ +extern alias controlapp; + +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ServerMonitorManager.Core; +using Xunit; + +namespace ServerMonitorManager.Control.Tests; + +public sealed class NodeEnrollmentCodeTests : IAsyncDisposable +{ + private readonly NodeEnrollmentTestFactory _factory = new(); + + [Fact] + public async Task AnonymousRequestIsRejectedWithUnauthorized() + { + using var anonymous = _factory.CreateClient(); + var response = await anonymous.PostAsync( + "/api/v1/control/agents/test-node/enrollment-code", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task AgentRoleIsRejectedWithForbidden() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "agent-node"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Agent"); + + var response = await client.PostAsync( + "/api/v1/control/agents/test-node/enrollment-code", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task AutomationRoleIsRejectedWithForbidden() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "automation-worker"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Automation"); + + var response = await client.PostAsync( + "/api/v1/control/agents/test-node/enrollment-code", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData(" ")] + [InlineData("NODE_UPPERCASE")] + [InlineData("node_with_underscore")] + [InlineData("node with spaces")] + [InlineData("this-node-name-is-way-too-long-and-exceeds-the-maximum-sixty-three-characters-limit")] + public async Task InvalidNodeIdIsRejectedWithBadRequestBeforeTokenCreation(string invalidNodeId) + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-1"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response = await client.PostAsync( + $"/api/v1/control/agents/{Uri.EscapeDataString(invalidNodeId)}/enrollment-code", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // Verify that no token was generated in database + var store = _factory.Services.GetRequiredService(); + var dbPath = _factory.DatabasePath; + await using var conn = new SqliteConnection($"Data Source={dbPath}"); + await conn.OpenAsync(TestContext.Current.CancellationToken); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM enrollment_tokens;"; + var count = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, count); + } + + [Fact] + public async Task EmptyNodeIdThrowsArgumentExceptionInService() + { + var service = _factory.Services.GetRequiredService(); + await Assert.ThrowsAsync(() => + service.CreateEnrollmentCodeAsync(string.Empty, "operator-1", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task OperatorCanRequestNodeEnrollmentCodeWithCorrectStructure() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response = await client.PostAsync( + "/api/v1/control/agents/node-alpha/enrollment-code", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("node-alpha", result.NodeId); + Assert.False(string.IsNullOrWhiteSpace(result.CaFingerprint)); + Assert.True(result.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(9)); + Assert.True(result.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(11)); + + // Format check: SMMNODE2........ + var segments = result.Code.Split('.'); + Assert.Equal(9, segments.Length); + Assert.Equal("SMMNODE2", segments[0]); + + var controlUrl = DecodeBase64UrlString(segments[1]); + var caPem = DecodeBase64UrlString(segments[2]); + var nodeId = DecodeBase64UrlString(segments[3]); + var token = DecodeBase64UrlString(segments[4]); + var hubEndpoint = DecodeBase64UrlString(segments[5]); + var hubPublicKey = DecodeBase64UrlString(segments[6]); + var nodeAddress = DecodeBase64UrlString(segments[7]); + var meshNetwork = DecodeBase64UrlString(segments[8]); + + Assert.Equal("https://hub.example.com:7443", controlUrl); + Assert.Contains("-----BEGIN CERTIFICATE-----", caPem); + Assert.Equal("node-alpha", nodeId); + Assert.Equal(43, token.Length); // 32 bytes base64url unpadded is 43 chars + Assert.Equal("hub.example.com:51820", hubEndpoint); + Assert.Equal("mQZ/Y4yQpQhX6j0rL8vU2w==", hubPublicKey); + Assert.Equal("10.77.0.2", nodeAddress); + Assert.Equal("10.77.0.0/24", meshNetwork); + } + + [Fact] + public async Task TwoDifferentNodeIdsGetDistinctMeshAddresses() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response1 = await client.PostAsync( + "/api/v1/control/agents/node-first/enrollment-code", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + var result1 = await response1.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + var response2 = await client.PostAsync( + "/api/v1/control/agents/node-second/enrollment-code", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + var result2 = await response2.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]); + var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]); + + Assert.Equal("10.77.0.2", address1); + Assert.Equal("10.77.0.3", address2); + Assert.NotEqual(address1, address2); + } + + [Fact] + public async Task RepeatedRequestForSameNodeIdReusesReservedAddress() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response1 = await client.PostAsync( + "/api/v1/control/agents/node-repeat/enrollment-code", + null, + TestContext.Current.CancellationToken); + var result1 = await response1.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + var response2 = await client.PostAsync( + "/api/v1/control/agents/node-repeat/enrollment-code", + null, + TestContext.Current.CancellationToken); + var result2 = await response2.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]); + var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]); + + Assert.Equal("10.77.0.2", address1); + Assert.Equal("10.77.0.2", address2); + + // Tokens should be distinct per request + var token1 = DecodeBase64UrlString(result1.Code.Split('.')[4]); + var token2 = DecodeBase64UrlString(result2.Code.Split('.')[4]); + Assert.NotEqual(token1, token2); + } + + [Fact] + public async Task AuditLogRecordsEnrollmentCodeIssuance() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-carol"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response = await client.PostAsync( + "/api/v1/control/agents/audited-node/enrollment-code", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var conn = new SqliteConnection($"Data Source={_factory.DatabasePath}"); + await conn.OpenAsync(TestContext.Current.CancellationToken); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT actor, action, subject, details_json FROM audit WHERE action = 'agent.enrollment_code.issued';"; + await using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken); + Assert.True(await reader.ReadAsync(TestContext.Current.CancellationToken)); + + var actor = reader.GetString(0); + var action = reader.GetString(1); + var subject = reader.GetString(2); + var detailsJson = reader.GetString(3); + + Assert.Equal("operator-carol", actor); + Assert.Equal("agent.enrollment_code.issued", action); + Assert.Equal("audited-node", subject); + Assert.Contains("audited-node", detailsJson); + Assert.Contains("10.77.0.2", detailsJson); + } + + [Fact] + public async Task CodeStructureMatchesBashReferenceFormatFixture() + { + // Fixture matching the reference implementation in create_node_code: + // SMMNODE2........ + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response = await client.PostAsync( + "/api/v1/control/agents/reference-node/enrollment-code", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync( + SmmJsonContext.Default.NodeEnrollmentCodeResponse, + TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var parts = result.Code.Split('.'); + Assert.Equal(9, parts.Length); + Assert.Equal("SMMNODE2", parts[0]); + + // Validate each segment character set (base64url characters only, no =) + for (int i = 1; i < parts.Length; i++) + { + Assert.Matches("^[A-Za-z0-9_-]+$", parts[i]); + Assert.DoesNotContain("=", parts[i]); + } + } + + public async ValueTask DisposeAsync() => await _factory.DisposeAsync(); + + private static string DecodeBase64UrlString(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch + { + 2 => "==", + 3 => "=", + 0 => string.Empty, + _ => throw new FormatException("Invalid base64url length.") + }; + return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); + } + + private sealed class NodeEnrollmentTestFactory : WebApplicationFactory + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"smm-node-enrollment-tests-{Guid.NewGuid():N}"); + + public string DatabasePath => Path.Combine(_directory, "control.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Directory.CreateDirectory(_directory); + var authorityPath = Path.Combine(_directory, "control-ca.pfx"); + if (!File.Exists(authorityPath)) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=SMM Node Code Test CA", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + using var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); + File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx)); + } + + var publicUrlPath = Path.Combine(_directory, "control-public-url"); + File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n"); + + var meshEnvPath = Path.Combine(_directory, "mesh.env"); + File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n"); + + var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv"); + + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Control:DatabasePath"] = DatabasePath, + ["Control:CertificateAuthorityPath"] = authorityPath, + ["Control:BackupDirectory"] = Path.Combine(_directory, "backups"), + ["Control:PublicUrlPath"] = publicUrlPath, + ["Control:MeshEnvironmentPath"] = meshEnvPath, + ["Control:MeshNodesPath"] = meshNodesPath + })); + + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Test"; + options.DefaultChallengeScheme = "Test"; + }) + .AddScheme("Test", _ => { }); + }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + try + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + catch + { + // Best effort cleanup in tests + } + } + } + + private sealed class TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity) + || !Request.Headers.TryGetValue("X-Test-Role", out var role)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + var claims = new List + { + new(ClaimTypes.NameIdentifier, identity.ToString()), + new(ClaimTypes.Role, role.ToString()) + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)); + return Task.FromResult(AuthenticateResult.Success( + new AuthenticationTicket(principal, Scheme.Name))); + } + } +} -- 2.45.2 From 93eb507f7a0e89ec9897117af8ad1c8df100ad56 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 00:03:37 +0700 Subject: [PATCH 2/3] fix(control): implement IDisposable on NodeEnrollmentService and register event payload in SmmJsonContext --- .../NodeEnrollmentService.cs | 17 +++++++++-------- .../NodeEnrollmentModels.cs | 6 ++++++ src/ServerMonitorManager.Core/SmmJsonContext.cs | 1 + 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/ServerMonitorManager.Control/NodeEnrollmentService.cs b/src/ServerMonitorManager.Control/NodeEnrollmentService.cs index b96c2f3..a9701e4 100644 --- a/src/ServerMonitorManager.Control/NodeEnrollmentService.cs +++ b/src/ServerMonitorManager.Control/NodeEnrollmentService.cs @@ -6,7 +6,7 @@ using ServerMonitorManager.Core; namespace ServerMonitorManager.Control; -public sealed class NodeEnrollmentService +public sealed class NodeEnrollmentService : IDisposable { private readonly IOptions _options; private readonly ControlStore _store; @@ -59,13 +59,9 @@ public sealed class NodeEnrollmentService _broker.Publish( "agent.enrollment_code.issued", nodeId, - JsonSerializer.Serialize(new - { - node_id = nodeId, - actor, - node_address = nodeAddress, - expires_at = expiresAt - })); + JsonSerializer.Serialize( + new NodeEnrollmentCodeIssuedDetails(nodeId, actor, nodeAddress, expiresAt), + SmmJsonContext.Default.NodeEnrollmentCodeIssuedDetails)); var code = string.Join(".", "SMMNODE2", @@ -220,4 +216,9 @@ public sealed class NodeEnrollmentService .Replace('+', '-') .Replace('/', '_'); } + + public void Dispose() + { + _meshLock.Dispose(); + } } diff --git a/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs b/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs index ab8a344..00415ab 100644 --- a/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs +++ b/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs @@ -5,3 +5,9 @@ public sealed record NodeEnrollmentCodeResponse( string Code, string CaFingerprint, DateTimeOffset ExpiresAt); + +public sealed record NodeEnrollmentCodeIssuedDetails( + string NodeId, + string Actor, + string NodeAddress, + DateTimeOffset ExpiresAt); diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index d0aeccf..3e2449e 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -58,4 +58,5 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(ProvisioningEvent))] [JsonSerializable(typeof(ProvisioningEvent[]))] [JsonSerializable(typeof(NodeEnrollmentCodeResponse))] +[JsonSerializable(typeof(NodeEnrollmentCodeIssuedDetails))] public sealed partial class SmmJsonContext : JsonSerializerContext; -- 2.45.2 From 55a19e9eeae4d09e8e802ce509feead97124b23d Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 00:35:38 +0700 Subject: [PATCH 3/3] refactor(control): remove duplicate /nodes route for enrollment code --- src/ServerMonitorManager.Control/Program.cs | 26 ------------------- .../NodeEnrollmentCodeTests.cs | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 05fc741..2780018 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -629,32 +629,6 @@ control.MapPost("/agents/{nodeId}/enrollment-code", async ( return Results.Conflict(new ProblemDetails { Title = exception.Message }); } }).RequireRateLimiting("enrollment"); -control.MapPost("/nodes/{nodeId}/enrollment-code", async ( - string nodeId, - HttpContext context, - NodeEnrollmentService enrollmentService, - CancellationToken cancellationToken) => -{ - if (!NodeIdValidator.IsValid(nodeId)) - { - return Results.ValidationProblem(new Dictionary - { - ["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."] - }); - } - - var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; - try - { - var response = await enrollmentService.CreateEnrollmentCodeAsync( - nodeId, actor, cancellationToken); - return Results.Ok(response); - } - catch (InvalidOperationException exception) - { - return Results.Conflict(new ProblemDetails { Title = exception.Message }); - } -}).RequireRateLimiting("enrollment"); control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); control.MapGet("/provisioning/catalogs/system-base-install/1", () => diff --git a/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs b/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs index da4875b..9a1c955 100644 --- a/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs @@ -96,7 +96,7 @@ public sealed class NodeEnrollmentCodeTests : IAsyncDisposable await conn.OpenAsync(TestContext.Current.CancellationToken); var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM enrollment_tokens;"; - var count = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + var count = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken), System.Globalization.CultureInfo.InvariantCulture); Assert.Equal(0, count); } -- 2.45.2