Merge remote-tracking branch 'origin/main' into codex/mesh-state-permissions

This commit is contained in:
ochenstarik-ui 2026-08-18 01:43:21 +07:00
commit 47a1a1d529
7 changed files with 708 additions and 0 deletions

View file

@ -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";
}

View file

@ -444,6 +444,32 @@ public sealed partial class ControlStore(IOptions<ControlOptions> 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<string> CreateDeviceEnrollmentTokenAsync(
string deviceId,
TimeSpan lifetime,

View file

@ -0,0 +1,224 @@
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 : IDisposable
{
private readonly IOptions<ControlOptions> _options;
private readonly ControlStore _store;
private readonly CertificateAuthority _authority;
private readonly ControlEventBroker _broker;
private readonly SemaphoreSlim _meshLock = new(1, 1);
public NodeEnrollmentService(
IOptions<ControlOptions> options,
ControlStore store,
CertificateAuthority authority,
ControlEventBroker broker)
{
_options = options;
_store = store;
_authority = authority;
_broker = broker;
}
public async Task<NodeEnrollmentCodeResponse> 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 NodeEnrollmentCodeIssuedDetails(nodeId, actor, nodeAddress, expiresAt),
SmmJsonContext.Default.NodeEnrollmentCodeIssuedDetails));
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<string> 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<string>();
var usedAddresses = new HashSet<string>(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('/', '_');
}
public void Dispose()
{
_meshLock.Dispose();
}
}

View file

@ -60,6 +60,7 @@ builder.Services.AddSingleton<ControlEventBroker>();
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
builder.Services.AddSingleton<LinkService>();
builder.Services.AddSingleton<CertificateLifecycleService>();
builder.Services.AddSingleton<NodeEnrollmentService>();
builder.Services.AddSingleton<ControlBackupService>();
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
@ -602,6 +603,32 @@ 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<string, string[]>
{
["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", () =>

View file

@ -0,0 +1,13 @@
namespace ServerMonitorManager.Core;
public sealed record NodeEnrollmentCodeResponse(
string NodeId,
string Code,
string CaFingerprint,
DateTimeOffset ExpiresAt);
public sealed record NodeEnrollmentCodeIssuedDetails(
string NodeId,
string Actor,
string NodeAddress,
DateTimeOffset ExpiresAt);

View file

@ -57,4 +57,6 @@ namespace ServerMonitorManager.Core;
[JsonSerializable(typeof(ProvisioningJob[]))]
[JsonSerializable(typeof(ProvisioningEvent))]
[JsonSerializable(typeof(ProvisioningEvent[]))]
[JsonSerializable(typeof(NodeEnrollmentCodeResponse))]
[JsonSerializable(typeof(NodeEnrollmentCodeIssuedDetails))]
public sealed partial class SmmJsonContext : JsonSerializerContext;

View file

@ -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<ControlStore>();
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), System.Globalization.CultureInfo.InvariantCulture);
Assert.Equal(0, count);
}
[Fact]
public async Task EmptyNodeIdThrowsArgumentExceptionInService()
{
var service = _factory.Services.GetRequiredService<NodeEnrollmentService>();
await Assert.ThrowsAsync<ArgumentException>(() =>
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<NodeEnrollmentCodeResponse>(
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.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
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<NodeEnrollmentCodeResponse>(
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<NodeEnrollmentCodeResponse>(
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<NodeEnrollmentCodeResponse>(
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<NodeEnrollmentCodeResponse>(
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.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
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<NodeEnrollmentCodeResponse>(
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<controlapp::Program>
{
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<string, string?>
{
["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<IHostedService>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Test";
options.DefaultChallengeScheme = "Test";
})
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("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<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> 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<Claim>
{
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)));
}
}
}