diff --git a/deploy/smm-setup.sh b/deploy/smm-setup.sh index bdfa785..a66e61c 100644 --- a/deploy/smm-setup.sh +++ b/deploy/smm-setup.sh @@ -3,7 +3,7 @@ set -Eeuo pipefail IFS=$'\n\t' readonly PROGRAM_NAME="smm-setup" -readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.16" +readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.18" readonly DEFAULT_REPOSITORY="ochenstarik-ui/server-monitor-manager" readonly INNER_ASSET="ochenstarik-server-monitor-manager.sh" @@ -27,7 +27,7 @@ force pass-through. Common bootstrap commands: backup-create | backup-restore | version Environment overrides: - SMM_TAG Release tag (default: v0.1.0-alpha.16) + SMM_TAG Release tag (default: v0.1.0-alpha.18) SMM_REPOSITORY GitHub repository (default: ochenstarik-ui/server-monitor-manager) SMM_CACHE_DIR Verified-download cache directory USAGE @@ -114,7 +114,7 @@ curl -fsSL "$release_base/$INNER_ASSET.sha256" -o "$temporary_directory/$INNER_A ( cd "$temporary_directory" - sha256sum -c "$INNER_ASSET.sha256" + sha256sum -c "$INNER_ASSET.sha256" >/dev/null ) || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET" install -m 0755 "$temporary_directory/$INNER_ASSET" "$cached_script" diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md index 54317a6..ae21afc 100644 --- a/docs/linux-bootstrap.md +++ b/docs/linux-bootstrap.md @@ -6,11 +6,11 @@ Server Monitor Manager устанавливает Control (Hub) и Agent (Node) ## Быстрая установка -Скачайте и проверьте convenience installer из `v0.1.0-alpha.16`: +Скачайте и проверьте convenience installer из `v0.1.0-alpha.18`: ```bash -curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.16/smm-setup.sh -curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.16/smm-setup.sh.sha256 +curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.18/smm-setup.sh +curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.18/smm-setup.sh.sha256 sha256sum -c smm-setup.sh.sha256 chmod 700 smm-setup.sh ``` diff --git a/docs/release-policy.md b/docs/release-policy.md index 2d6e1ef..8dd3ae6 100644 --- a/docs/release-policy.md +++ b/docs/release-policy.md @@ -17,6 +17,8 @@ Known release history: - `v0.1.0-alpha.13` corrected the checksum portability defect, but it was also published with a keyless manifest signature and without the Fulcio signing certificate required by production consumers. The immutable release remains published as historical evidence; the producer/consumer certificate contract is corrected under a higher version. - `v0.1.0-alpha.14` is the first release with the complete manifest, keyless signature, and Fulcio certificate set, so its published assets can be verified. A clean host cannot install it because the release does not provision cosign. Preserve it for verification and historical evidence; do not use it for installation. - `v0.1.0-alpha.15` is the first release that provisions a pinned, checksum-verified cosign binary. Its automatically triggered Release Verification proved the clean-host Hub installation and manifest verification, then stopped before the clean-host Node installation because the acceptance script retained the deliberately removed cosign path in Bash's command hash. The immutable release and failed verification remain as evidence; the acceptance harness is corrected in the next version. -- `v0.1.0-alpha.16` clears the acceptance shell's command hash after removing its test-provisioned cosign, so both `install-hub` and `install-node` are exercised from a clean host. It is the first release required to complete both automatic `workflow_run` verification and manual `workflow_dispatch` re-verification. +- `v0.1.0-alpha.16` clears the acceptance shell's command hash after removing its test-provisioned cosign. Its automatic verification proved clean-host Hub installation, but the convenience installer wrote the bootstrap checksum success line to stdout before the machine-readable `SMMNODE2` enrollment code. Node installation therefore rejected the contaminated value. The immutable release and failed verification remain as evidence; stdout isolation is corrected in the next version. +- `v0.1.0-alpha.17` keeps checksum verification fail-closed while suppressing its success line, so pass-through commands such as `node-code` return only their machine-readable bootstrap output. Its automatic verification completed the clean-host Hub and Node installation, then the negative-test harness stopped while creating a wrong-identity signature because cosign v3 requires an explicit bundle or legacy detached-output mode. The immutable release and failed harness run remain as evidence; the negative test is corrected in the next version. +- `v0.1.0-alpha.18` makes the wrong-identity negative test select cosign v3's explicit detached-output mode, matching the production consumer's detached signature and certificate contract. It is the first release required to complete both automatic `workflow_run` verification and manual `workflow_dispatch` re-verification. Every release candidate must pass a branch `workflow_dispatch` run of the Release pipeline before its immutable version tag is created. The release owner has sole write ownership of version sources, `deploy/**`, `tests/bootstrap/**`, release workflows, the root README release status, and translated README release statuses. Other contributors request changes to those paths in their report; they do not edit or bump them directly. One pull request covers one release topic and may merge only after required CI is green. 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..a9701e4 --- /dev/null +++ b/src/ServerMonitorManager.Control/NodeEnrollmentService.cs @@ -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 _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 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 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('/', '_'); + } + + public void Dispose() + { + _meshLock.Dispose(); + } +} diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 94b18de..2780018 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,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 + { + ["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..00415ab --- /dev/null +++ b/src/ServerMonitorManager.Core/NodeEnrollmentModels.cs @@ -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); diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index ec2a766..3e2449e 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -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; diff --git a/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs b/tests/ServerMonitorManager.Control.Tests/NodeEnrollmentCodeTests.cs new file mode 100644 index 0000000..9a1c955 --- /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), System.Globalization.CultureInfo.InvariantCulture); + 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))); + } + } +} diff --git a/tests/bootstrap/test-release-contract.sh b/tests/bootstrap/test-release-contract.sh index 1dfab22..e2d1752 100644 --- a/tests/bootstrap/test-release-contract.sh +++ b/tests/bootstrap/test-release-contract.sh @@ -18,7 +18,7 @@ v1_fixture="$root/tests/fixtures/alpha8-v1-release" exit 1 } bash -n "$setup" -grep -Fq 'readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.16"' "$setup" +grep -Fq 'readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.18"' "$setup" grep -Fq 'install-hub PUBLIC_HOST [HTTPS_PORT] [WG_PORT]' "$setup" grep -Fxq ' install-node' "$setup" if grep -Fq 'validate_control_url' "$setup" || grep -Fq '${CONTROL_URL%/}/control' "$setup"; then @@ -61,7 +61,11 @@ grep -Fq 'v0.1.0-alpha.13' "$policy" grep -Fq 'v0.1.0-alpha.14' "$policy" grep -Fq 'v0.1.0-alpha.15' "$policy" grep -Fq 'v0.1.0-alpha.16' "$policy" +grep -Fq 'v0.1.0-alpha.17' "$policy" +grep -Fq 'v0.1.0-alpha.18' "$policy" grep -Fq 'hash -r' "$root/tests/release-verification/run-positive-installation.sh" +grep -Fq -- '--use-signing-config=false --new-bundle-format=false' \ + "$root/tests/release-verification/run-negative-tests.sh" grep -Fq 'readonly COSIGN_VERSION="v3.1.3"' "$bootstrap" grep -Fq 'readonly COSIGN_SHA256_AMD64="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"' "$bootstrap" grep -Fq 'readonly COSIGN_SHA256_ARM64="c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a"' "$bootstrap" @@ -109,6 +113,10 @@ mkdir -p "$work/bin" "$work/home" cat >"$work/inner.sh" <<'INNER' #!/usr/bin/env bash set -Eeuo pipefail +if [[ "${1:-}" == "node-code" ]]; then + printf '%s\n' 'SMMNODE1.fixture' + exit 0 +fi printf 'INNER_ARGS=' printf '%s ' "$@" printf '\n' @@ -185,8 +193,14 @@ chmod +x "$work/bin/uname" HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" version >"$work/output" grep -Fq 'INNER_ARGS=version ' "$work/output" -grep -Fq '/releases/download/v0.1.0-alpha.16/ochenstarik-server-monitor-manager.sh' "$work/urls" -grep -Fq '/releases/download/v0.1.0-alpha.16/ochenstarik-server-monitor-manager.sh.sha256' "$work/urls" +grep -Fq '/releases/download/v0.1.0-alpha.18/ochenstarik-server-monitor-manager.sh' "$work/urls" +grep -Fq '/releases/download/v0.1.0-alpha.18/ochenstarik-server-monitor-manager.sh.sha256' "$work/urls" + +node_code="$(HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" node-code fixture-node)" +[[ "$node_code" == 'SMMNODE1.fixture' ]] || { + printf 'machine-readable node-code output was contaminated: %q\n' "$node_code" >&2 + exit 1 +} if HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" install-hub >"$work/invalid.out" 2>&1; then printf '%s\n' 'install-hub accepted a missing PUBLIC_HOST' >&2 diff --git a/tests/release-verification/run-negative-tests.sh b/tests/release-verification/run-negative-tests.sh index a4bde31..34a18d4 100644 --- a/tests/release-verification/run-negative-tests.sh +++ b/tests/release-verification/run-negative-tests.sh @@ -72,6 +72,7 @@ echo "Test 4: Signature made by another identity" export COSIGN_PASSWORD="" cosign generate-key-pair >/dev/null cosign sign-blob --yes --key cosign.key \ + --use-signing-config=false --new-bundle-format=false \ --output-signature fake.sig server-monitor-manager-manifest.json >/dev/null if ./ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json \ fake.sig server-monitor-manager-manifest.pem >/dev/null 2>&1; then